Skip to content

Commit a2b29db

Browse files
elkaixTest User
authored andcommitted
feat: show what trusting a workspace would activate
The trust prompt and SDK trust info now disclose gated MCP servers with their config origins, additional directory grants, and the project instruction sources (AGENTS.md, skills, agent profiles) that load on trust.
1 parent 643144c commit a2b29db

11 files changed

Lines changed: 804 additions & 216 deletions

File tree

‎apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts‎

Lines changed: 153 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1211
import { 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

1515
export type TrustPromptChoice = 'trust' | 'distrust';
1616

1717
export 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

4429
export 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-
*/
132218
function sanitizeForDisplay(value: string): string {
133219
let result = '';
134220
for (const char of value) {

‎apps/pythinker-code/src/tui/pythinker-tui.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3878,7 +3878,14 @@ export class PythinkerTUI {
38783878
try {
38793879
info = await this.harness.getWorkspaceTrustInfo(workDir);
38803880
} catch {
3881-
info = { trusted: false, gatedMcpServers: [] };
3881+
info = {
3882+
trusted: false,
3883+
gatedMcpServers: [],
3884+
gatedAdditionalDirs: [],
3885+
additionalDirSources: [],
3886+
warnings: ['Could not inspect project settings.'],
3887+
instructionSources: { agentsMdPaths: [], skills: [], agentProfiles: [], paths: [] },
3888+
};
38823889
}
38833890
if (info.trusted) {
38843891
return false;
@@ -3889,7 +3896,9 @@ export class PythinkerTUI {
38893896
this.mountEditorReplacement(
38903897
new TrustPromptComponent({
38913898
workDir,
3892-
gatedMcpServers: info.gatedMcpServers,
3899+
info,
3900+
getAvailableRows: () =>
3901+
this.state.terminal.rows - (this.state.ui instanceof TuiAltScreen ? 1 : 0),
38933902
onSelect: (c) => {
38943903
resolve(c);
38953904
},

0 commit comments

Comments
 (0)