@@ -6,44 +6,32 @@ import {
66 type Component ,
77 type Focusable ,
88} from '@pymodel/pi-tui' ;
9-
10- import type { WorkspaceTrustMcpServerInfo } from '@pymodel/pythinker-code-sdk' ;
9+ import type { WorkspaceTrustInfo } from '@pymodel/pythinker-code-sdk' ;
1110
1211import { SELECT_POINTER } from '#/tui/constant/symbols' ;
13- import { currentTheme } from '#/tui/theme' ;
12+ import { currentTheme , type ColorToken } from '#/tui/theme' ;
13+ import { pageView } from '#/tui/utils/paging' ;
1414
1515export type TrustPromptChoice = 'trust' | 'distrust' ;
1616
1717export interface TrustPromptOptions {
1818 readonly workDir : string ;
19- /** Project-level MCP servers that trusting would enable; may be empty. */
20- readonly gatedMcpServers : readonly WorkspaceTrustMcpServerInfo [ ] ;
21- /** Esc resolves to 'distrust' as well. */
19+ readonly info : WorkspaceTrustInfo ;
20+ readonly getAvailableRows ?: ( ) => number ;
2221 readonly onSelect : ( choice : TrustPromptChoice ) => void ;
2322}
2423
25- interface TrustPromptOption {
26- readonly value : TrustPromptChoice ;
27- readonly label : string ;
28- readonly description : string ;
29- }
30-
31- const OPTIONS : readonly TrustPromptOption [ ] = [
32- {
33- value : 'trust' ,
34- label : 'Trust this folder' ,
35- description : 'Enable project MCP servers. Remembered for this folder.' ,
36- } ,
37- {
38- value : 'distrust' ,
39- label : "Don't trust" ,
40- description : 'Exit Pythinker Code. Asked again next launch.' ,
41- } ,
24+ const OPTIONS : readonly { value : TrustPromptChoice ; label : string } [ ] = [
25+ { value : 'trust' , label : 'Trust and continue' } ,
26+ { value : 'distrust' , label : 'Exit' } ,
4227] ;
4328
4429export class TrustPromptComponent implements Component , Focusable {
4530 focused = false ;
4631 private selectedIndex = 0 ;
32+ private disclosureIndex = 0 ;
33+ private disclosurePageSize = 1 ;
34+ private canConfirm = true ;
4735
4836 constructor ( private readonly opts : TrustPromptOptions ) { }
4937
@@ -62,73 +50,171 @@ export class TrustPromptComponent implements Component, Focusable {
6250 this . selectedIndex = Math . min ( OPTIONS . length - 1 , this . selectedIndex + 1 ) ;
6351 return ;
6452 }
65- if ( matchesKey ( data , Key . enter ) ) {
53+ const previousPage = matchesKey ( data , Key . left ) || matchesKey ( data , Key . pageUp ) ;
54+ const nextPage = matchesKey ( data , Key . right ) || matchesKey ( data , Key . pageDown ) ;
55+ if ( previousPage || nextPage ) {
56+ this . disclosureIndex = Math . max (
57+ 0 ,
58+ this . disclosureIndex + ( previousPage ? - 1 : 1 ) * this . disclosurePageSize ,
59+ ) ;
60+ return ;
61+ }
62+ if ( this . canConfirm && ( matchesKey ( data , Key . enter ) || matchesKey ( data , Key . space ) ) ) {
6663 this . opts . onSelect ( OPTIONS [ this . selectedIndex ] ! . value ) ;
6764 }
6865 }
6966
7067 render ( width : number ) : string [ ] {
7168 const rule = currentTheme . fg ( 'primary' , '─' . repeat ( width ) ) ;
72- const lines = [
69+ const availableRows = Math . max ( 0 , Math . floor ( this . opts . getAvailableRows ?.( ) ?? Infinity ) ) ;
70+ const header = [
7371 rule ,
7472 currentTheme . boldFg ( 'primary' , ' Trust this folder?' ) ,
7573 currentTheme . fg ( 'textMuted' , ' ↑↓ navigate · Enter select · Esc exit' ) ,
7674 '' ,
77- ...wrapTextWithAnsi ( this . opts . workDir , Math . max ( 20 , width - 2 ) ) . map (
78- ( line ) => ` ${ currentTheme . fg ( 'textStrong' , line ) } ` ,
75+ ] ;
76+ const body = [
77+ ...wrap ( this . opts . workDir , 1 , width , 'textStrong' ) ,
78+ '' ,
79+ ...this . renderDisclosure ( width ) ,
80+ ] ;
81+ const footer = [
82+ ...wrap (
83+ 'Trust is remembered for this folder, including future project config changes.' ,
84+ 1 ,
85+ width ,
86+ 'textMuted' ,
7987 ) ,
88+ ...wrap ( 'Tool approvals follow your permission settings.' , 1 , width , 'textMuted' ) ,
8089 '' ,
90+ ...OPTIONS . map ( ( option , i ) => {
91+ const selected = i === this . selectedIndex ;
92+ const pointer = selected ? SELECT_POINTER : ' ' ;
93+ const label = selected
94+ ? currentTheme . boldFg ( 'primary' , option . label )
95+ : currentTheme . fg ( 'text' , option . label ) ;
96+ return currentTheme . fg ( selected ? 'primary' : 'textDim' , ` ${ pointer } ` ) + label ;
97+ } ) ,
98+ rule ,
8199 ] ;
82-
83- const notice =
84- 'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.' ;
85- for ( const line of wrapTextWithAnsi ( notice , Math . max ( 20 , width - 2 ) ) ) {
86- lines . push ( ` ${ currentTheme . fg ( 'textMuted' , line ) } ` ) ;
100+ this . canConfirm = header . length + footer . length + 2 <= availableRows ;
101+ if ( ! this . canConfirm ) {
102+ return [ header [ 1 ] ! , ' Enlarge terminal to review sources. Esc exit.' ]
103+ . slice ( 0 , availableRows )
104+ . map ( ( line ) => truncateToWidth ( line , width ) ) ;
87105 }
88- if ( this . opts . gatedMcpServers . length > 0 ) {
89- lines . push ( ` ${ currentTheme . fg ( 'warning' , 'Project MCP targets:' ) } ` ) ;
90- for ( const server of this . opts . gatedMcpServers ) {
91- const details = formatMcpTarget ( server ) ;
92- for ( const line of wrapTextWithAnsi ( details , Math . max ( 20 , width - 4 ) ) ) {
93- lines . push ( ` ${ currentTheme . fg ( 'warning' , line ) } ` ) ;
94- }
95- }
96- }
97- lines . push ( '' ) ;
106+ const needsPaging = header . length + body . length + footer . length > availableRows ;
107+ this . disclosurePageSize = needsPaging
108+ ? availableRows - header . length - footer . length - 1
109+ : body . length ;
110+ const page = pageView ( body . length , this . disclosureIndex , this . disclosurePageSize ) ;
111+ this . disclosureIndex = page . start ;
112+ const lines = [ ...header , ...body . slice ( page . start , page . end ) ] ;
113+ while ( lines . length < header . length + this . disclosurePageSize ) lines . push ( '' ) ;
114+ if ( page . pageCount > 1 )
115+ lines . push ( currentTheme . fg ( 'textMuted' , ` ←→ page · ${ page . page + 1 } / ${ page . pageCount } ` ) ) ;
116+ lines . push ( ...footer ) ;
117+ return lines . map ( ( line ) => truncateToWidth ( line , width ) ) ;
118+ }
98119
99- for ( let i = 0 ; i < OPTIONS . length ; i += 1 ) {
100- const option = OPTIONS [ i ] ! ;
101- const selected = i === this . selectedIndex ;
102- const pointer = selected ? SELECT_POINTER : ' ' ;
103- const label = selected
104- ? currentTheme . boldFg ( 'primary' , option . label )
105- : currentTheme . fg ( 'text' , option . label ) ;
106- lines . push ( currentTheme . fg ( selected ? 'primary' : 'textDim' , ` ${ pointer } ` ) + label ) ;
107- for ( const line of wrapTextWithAnsi ( option . description , Math . max ( 20 , width - 4 ) ) ) {
108- lines . push ( ` ${ currentTheme . fg ( 'textMuted' , line ) } ` ) ;
120+ private renderDisclosure ( width : number ) : string [ ] {
121+ const {
122+ gatedMcpServers,
123+ gatedAdditionalDirs,
124+ additionalDirSources,
125+ instructionSources,
126+ warnings,
127+ } = this . opts . info ;
128+ const lines : string [ ] = [ ] ;
129+ if ( gatedMcpServers . length > 0 ) {
130+ lines . push (
131+ ...wrap (
132+ `Start ${ gatedMcpServers . length } MCP ${
133+ gatedMcpServers . length === 1 ? 'server' : 'servers'
134+ } automatically`,
135+ 1 ,
136+ width ,
137+ 'warning' ,
138+ ) ,
139+ ) ;
140+ const origins = [ ...new Set ( gatedMcpServers . map ( ( server ) => server . origin ) ) ] ;
141+ lines . push (
142+ ...wrap (
143+ `Config: ${ origins . map ( ( path ) => relativize ( this . opts . workDir , path ) ) . join ( ', ' ) } ` ,
144+ 3 ,
145+ width ,
146+ 'textMuted' ,
147+ ) ,
148+ '' ,
149+ ) ;
150+ }
151+ if ( gatedAdditionalDirs . length > 0 ) {
152+ lines . push (
153+ ...wrap (
154+ `Access ${ gatedAdditionalDirs . length } ${
155+ gatedAdditionalDirs . length === 1 ? 'folder' : 'folders'
156+ } outside this project`,
157+ 1 ,
158+ width ,
159+ 'warning' ,
160+ ) ,
161+ ) ;
162+ if ( additionalDirSources . length > 0 ) {
163+ lines . push (
164+ ...wrap (
165+ `Config: ${ additionalDirSources
166+ . map ( ( path ) => relativize ( this . opts . workDir , path ) )
167+ . join ( ', ' ) } `,
168+ 3 ,
169+ width ,
170+ 'textMuted' ,
171+ ) ,
172+ ) ;
109173 }
110174 lines . push ( '' ) ;
111175 }
112-
113- lines . push ( rule ) ;
114- return lines . map ( ( line ) => truncateToWidth ( line , width ) ) ;
176+ if ( instructionSources . paths . length > 0 ) {
177+ const hasInstructions =
178+ instructionSources . agentsMdPaths . length > 0 || instructionSources . skills . length > 0 ;
179+ const subject = hasInstructions
180+ ? instructionSources . agentProfiles . length > 0
181+ ? 'instructions and agent profiles'
182+ : 'instructions'
183+ : 'agent profiles' ;
184+ lines . push ( ...wrap ( `Load project ${ subject } ` , 1 , width , 'text' ) ) ;
185+ lines . push (
186+ ...wrap (
187+ `Check: ${ instructionSources . paths . map ( ( path ) => relativize ( this . opts . workDir , path ) ) . join ( ' · ' ) } ` ,
188+ 3 ,
189+ width ,
190+ 'textMuted' ,
191+ ) ,
192+ '' ,
193+ ) ;
194+ }
195+ for ( const warning of warnings ) lines . push ( ...wrap ( warning , 1 , width , 'warning' ) ) ;
196+ if ( lines . length === 0 )
197+ lines . push (
198+ ...wrap ( 'No project integrations or instructions to activate.' , 1 , width , 'textMuted' ) ,
199+ '' ,
200+ ) ;
201+ return lines ;
115202 }
116203}
117204
118- function formatMcpTarget ( server : WorkspaceTrustMcpServerInfo ) : string {
119- if ( server . transport === 'stdio' ) {
120- const args = server . args === undefined ? '' : ` args=${ JSON . stringify ( server . args ) } ` ;
121- const cwd = server . cwd === undefined ? '' : ` cwd=${ server . cwd } ` ;
122- return sanitizeForDisplay ( `${ server . name } (stdio): command=${ server . command ?? '' } ${ args } ${ cwd } ` ) ;
123- }
124- return sanitizeForDisplay ( `${ server . name } (${ server . transport } ): url=${ server . url ?? '' } ` ) ;
205+ function wrap ( text : string , indent : number , width : number , color : ColorToken ) : string [ ] {
206+ return wrapTextWithAnsi ( sanitizeForDisplay ( text ) , Math . max ( 1 , width - indent ) ) . map (
207+ ( line ) => `${ ' ' . repeat ( indent ) } ${ currentTheme . fg ( color , line ) } ` ,
208+ ) ;
209+ }
210+
211+ function relativize ( workDir : string , path : string ) : string {
212+ const normalizedDir = workDir . replaceAll ( '\\' , '/' ) ;
213+ const normalizedPath = path . replaceAll ( '\\' , '/' ) ;
214+ const prefix = normalizedDir . endsWith ( '/' ) ? normalizedDir : `${ normalizedDir } /` ;
215+ return normalizedPath . startsWith ( prefix ) ? normalizedPath . slice ( prefix . length ) : normalizedPath ;
125216}
126217
127- /**
128- * Drops C0/C1 control characters (including ESC) from workspace-supplied text:
129- * the trust prompt renders before the workspace is trusted, so a planted
130- * `.mcp.json` must not inject terminal control sequences into it.
131- */
132218function sanitizeForDisplay ( value : string ) : string {
133219 let result = '' ;
134220 for ( const char of value ) {
0 commit comments