pi-mcp-adapter 3.2.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/OAUTH.md +4 -2
- package/README.md +72 -10
- package/cli.js +1 -0
- package/commands.ts +20 -2
- package/config.ts +19 -1
- package/direct-tool-surface.ts +3 -2
- package/direct-tools.ts +12 -2
- package/dist/config.d.ts +6 -0
- package/dist/config.js +15 -0
- package/dist/config.js.map +1 -1
- package/dist/elicitation-handler.d.ts +9 -1
- package/dist/elicitation-handler.js +65 -2
- package/dist/elicitation-handler.js.map +1 -1
- package/dist/jev-client.js +7 -5
- package/dist/jev-client.js.map +1 -1
- package/dist/jev-key-store.d.ts +1 -0
- package/dist/jev-key-store.js +11 -0
- package/dist/jev-key-store.js.map +1 -1
- package/dist/mcp-auth-flow.d.ts +2 -0
- package/dist/mcp-auth-flow.js +12 -3
- package/dist/mcp-auth-flow.js.map +1 -1
- package/dist/metadata-cache.d.ts +12 -0
- package/dist/metadata-cache.js +50 -6
- package/dist/metadata-cache.js.map +1 -1
- package/dist/output-shape.d.ts +36 -0
- package/dist/output-shape.js +338 -0
- package/dist/output-shape.js.map +1 -0
- package/dist/package-mcp-loader.js +9 -5
- package/dist/package-mcp-loader.js.map +1 -1
- package/dist/server-manager.js +27 -16
- package/dist/server-manager.js.map +1 -1
- package/dist/state.d.ts +6 -0
- package/dist/ts-shape.d.ts +3 -0
- package/dist/ts-shape.js +153 -0
- package/dist/ts-shape.js.map +1 -0
- package/dist/types.d.ts +24 -3
- package/dist/types.js.map +1 -1
- package/dist/unix-socket-transport.js +1 -1
- package/dist/unix-socket-transport.js.map +1 -1
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +17 -0
- package/dist/utils.js.map +1 -1
- package/elicitation-handler.ts +78 -1
- package/index.ts +72 -12
- package/init.ts +20 -5
- package/jev-client.ts +7 -4
- package/jev-key-store.ts +7 -0
- package/mcp-auth-flow.ts +13 -2
- package/mcp-code.ts +126 -68
- package/mcp-output-guard.ts +24 -5
- package/mcp-script-worker.mjs +6 -2
- package/mcp-setup-panel.ts +583 -302
- package/metadata-cache.ts +60 -5
- package/output-shape.ts +348 -0
- package/package-mcp-loader.ts +8 -5
- package/package.json +6 -6
- package/prompts.ts +68 -0
- package/proxy-modes.ts +149 -106
- package/server-manager.ts +28 -15
- package/skills/mcp-scripting/SKILL.md +24 -28
- package/skills/mcp-scripting/references/jev.md +3 -0
- package/state.ts +6 -0
- package/tool-approval.ts +17 -4
- package/tool-metadata.ts +3 -1
- package/ts-shape.ts +1 -1
- package/types.ts +22 -3
- package/ui-server.ts +5 -2
- package/unix-socket-transport.ts +1 -1
- package/utils.ts +16 -1
package/mcp-setup-panel.ts
CHANGED
|
@@ -4,13 +4,31 @@ import { createMcpPanelTheme, McpPanelFrame, type McpPanelTheme } from "./mcp-pa
|
|
|
4
4
|
import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
|
|
5
5
|
import type { ImportKind } from "./types.ts";
|
|
6
6
|
import { getConfigDirName } from "./agent-dir.ts";
|
|
7
|
-
import {
|
|
7
|
+
import type { ConfigWritePreview, KnownServerPreset, McpDiscoverySummary, SharedConfigTarget } from "./config.ts";
|
|
8
8
|
import type { McpOnboardingState } from "./onboarding-state.ts";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { basename } from "node:path";
|
|
9
11
|
|
|
10
12
|
const MIN_PANEL_WIDTH = 24;
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
/** Blank columns between the frame border and the content on each side. */
|
|
14
|
+
const INSET = 2;
|
|
15
|
+
/** Below this inner width the list and details stack instead of sitting side by side. */
|
|
16
|
+
const TWO_PANE_MIN_INNER_WIDTH = 72;
|
|
17
|
+
const MIN_LIST_WIDTH = 30;
|
|
18
|
+
const MAX_LIST_WIDTH = 38;
|
|
19
|
+
/** Width of the ` │ ` gutter between the list and details panes. */
|
|
20
|
+
const PANE_GUTTER = 4;
|
|
21
|
+
/** Rows outside the body: top border, header, separator, hints, bottom border. */
|
|
22
|
+
const FRAME_ROWS = 5;
|
|
23
|
+
/** Blank rows under the header and above the separator, dropped when the terminal is short. */
|
|
24
|
+
const SPACER_ROWS = 2;
|
|
25
|
+
/** The spacer rows are kept only while the body still gets at least this many rows. */
|
|
26
|
+
const MIN_SPACED_BODY_ROWS = 10;
|
|
27
|
+
/** Body height cap, also used when the terminal size is unknown. */
|
|
28
|
+
const MAX_BODY_ROWS = 22;
|
|
29
|
+
const MIN_BODY_ROWS = 3;
|
|
30
|
+
/** Rows kept free above and below the overlay; matches overlayOptions.margin in commands.ts. */
|
|
31
|
+
const OVERLAY_VERTICAL_MARGIN = 1;
|
|
14
32
|
|
|
15
33
|
function wrapText(text: string, width: number): string[] {
|
|
16
34
|
if (width <= 8) return [text];
|
|
@@ -30,6 +48,65 @@ function wrapText(text: string, width: number): string[] {
|
|
|
30
48
|
return lines.length > 0 ? lines : [""];
|
|
31
49
|
}
|
|
32
50
|
|
|
51
|
+
/** Wraps prose, keeping leading indentation and list markers as a hanging indent. */
|
|
52
|
+
function wrapIndented(text: string, width: number): string[] {
|
|
53
|
+
const indent = /^\s*(?:\d+\.\s+|[-•]\s+)?/.exec(text)![0];
|
|
54
|
+
const body = text.slice(indent.length);
|
|
55
|
+
if (!body.trim()) return [text.trimEnd()];
|
|
56
|
+
const indentWidth = visibleWidth(indent);
|
|
57
|
+
return wrapText(body, Math.max(8, width - indentWidth))
|
|
58
|
+
.map((line, index) => `${index === 0 ? indent : " ".repeat(indentWidth)}${line}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const graphemes = new Intl.Segmenter();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Splits text into lines of at most `width` columns for values like paths that
|
|
65
|
+
* have no spaces to wrap at. Works on whole graphemes, so wide characters and
|
|
66
|
+
* emoji are never split.
|
|
67
|
+
*/
|
|
68
|
+
function hardWrap(text: string, width: number): string[] {
|
|
69
|
+
if (width <= 0 || visibleWidth(text) <= width) return [text];
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
let current = "";
|
|
72
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
73
|
+
if (current && visibleWidth(current + segment) > width) {
|
|
74
|
+
lines.push(current);
|
|
75
|
+
current = "";
|
|
76
|
+
}
|
|
77
|
+
current += segment;
|
|
78
|
+
}
|
|
79
|
+
if (current) lines.push(current);
|
|
80
|
+
return lines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cuts plain (unstyled) text to `width` columns with a trailing …. Styling is
|
|
85
|
+
* applied after fitting, so no reset codes end up inside the panel.
|
|
86
|
+
*/
|
|
87
|
+
function fitText(text: string, width: number, pad = false): string {
|
|
88
|
+
let fitted = text;
|
|
89
|
+
if (visibleWidth(text) > width) {
|
|
90
|
+
fitted = "";
|
|
91
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
92
|
+
if (visibleWidth(fitted + segment) > width - 1) break;
|
|
93
|
+
fitted += segment;
|
|
94
|
+
}
|
|
95
|
+
fitted = width > 0 ? `${fitted}…` : "";
|
|
96
|
+
}
|
|
97
|
+
return pad ? `${fitted}${" ".repeat(Math.max(0, width - visibleWidth(fitted)))}` : fitted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function shortenPath(path: string): string {
|
|
101
|
+
const home = homedir();
|
|
102
|
+
if (home && (path === home || path.startsWith(`${home}/`))) return `~${path.slice(home.length)}`;
|
|
103
|
+
return path;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function plural(count: number, noun: string): string {
|
|
107
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
33
110
|
export interface SetupPanelCallbacks {
|
|
34
111
|
previewImports: (imports: ImportKind[]) => ConfigWritePreview;
|
|
35
112
|
previewStarterConfig: (target: SharedConfigTarget) => ConfigWritePreview;
|
|
@@ -38,7 +115,7 @@ export interface SetupPanelCallbacks {
|
|
|
38
115
|
adoptImports: (imports: ImportKind[]) => Promise<{ added: ImportKind[]; path: string }>;
|
|
39
116
|
scaffoldConfig: (target: SharedConfigTarget) => Promise<{ path: string }>;
|
|
40
117
|
addRepoPrompt: (target: SharedConfigTarget) => Promise<{ path: string; serverName: string }>;
|
|
41
|
-
addKnownServer: (preset: KnownServerPreset, target: SharedConfigTarget) => Promise<{ path: string; serverName: string }>;
|
|
118
|
+
addKnownServer: (preset: KnownServerPreset, target: SharedConfigTarget) => Promise<{ path: string; serverName: string; reachable?: boolean }>;
|
|
42
119
|
openPath: (path: string) => Promise<void>;
|
|
43
120
|
markSetupCompleted: () => void;
|
|
44
121
|
}
|
|
@@ -50,6 +127,12 @@ export interface SetupPanelOptions {
|
|
|
50
127
|
theme?: Theme;
|
|
51
128
|
}
|
|
52
129
|
|
|
130
|
+
/** The subset of Pi's TUI the setup panel uses. `terminal` sizes the panel to the screen. */
|
|
131
|
+
export interface SetupPanelTui {
|
|
132
|
+
requestRender(): void;
|
|
133
|
+
terminal?: { rows: number };
|
|
134
|
+
}
|
|
135
|
+
|
|
53
136
|
type Screen = "empty" | "setup" | "imports" | "paths";
|
|
54
137
|
|
|
55
138
|
type ActionId =
|
|
@@ -61,16 +144,19 @@ type ActionId =
|
|
|
61
144
|
| "open-paths"
|
|
62
145
|
| "add-repoprompt"
|
|
63
146
|
| "add-known-server"
|
|
64
|
-
| "scaffold-shared-config"
|
|
65
|
-
| "close";
|
|
147
|
+
| "scaffold-shared-config";
|
|
66
148
|
|
|
67
149
|
interface Action {
|
|
68
150
|
id: ActionId;
|
|
69
151
|
label: string;
|
|
152
|
+
/** Muted text shown after the label, such as a config path. */
|
|
153
|
+
detail?: string;
|
|
70
154
|
preset?: KnownServerPreset;
|
|
71
155
|
target?: SharedConfigTarget;
|
|
72
156
|
}
|
|
73
157
|
|
|
158
|
+
type Notice = { text: string; tone: "success" | "warning" | "muted" };
|
|
159
|
+
|
|
74
160
|
interface McpSetupPanelViewState {
|
|
75
161
|
screen: Screen;
|
|
76
162
|
actionCursor: number;
|
|
@@ -78,19 +164,62 @@ interface McpSetupPanelViewState {
|
|
|
78
164
|
pathCursor: number;
|
|
79
165
|
sharedConfigTarget: SharedConfigTarget;
|
|
80
166
|
selectedImports: ReadonlySet<ImportKind>;
|
|
81
|
-
notice:
|
|
167
|
+
notice: Notice | null;
|
|
168
|
+
busy: boolean;
|
|
169
|
+
/** Lines the details pane is scrolled down; the view clamps it to the content. */
|
|
170
|
+
detailScroll: number;
|
|
82
171
|
onboardingState: McpOnboardingState;
|
|
83
172
|
discovery: McpDiscoverySummary;
|
|
84
173
|
actions: readonly Action[];
|
|
85
174
|
detectedPaths: readonly string[];
|
|
175
|
+
terminalRows?: number | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One line of the details pane. `text` is plain; `tone` styles it after it is fitted to the pane. */
|
|
179
|
+
interface PaneLine {
|
|
180
|
+
text: string;
|
|
181
|
+
tone?: (text: string) => string;
|
|
182
|
+
/** Marks the details heading, whose blank row below is dropped in compact layouts. */
|
|
183
|
+
heading?: boolean;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type ListEntry =
|
|
187
|
+
| { kind: "header"; text: string }
|
|
188
|
+
| { kind: "blank" }
|
|
189
|
+
| { kind: "item"; label: string; detail?: string | undefined; selected: boolean };
|
|
190
|
+
|
|
191
|
+
type ActionSection = "start" | "target" | "servers" | "files";
|
|
192
|
+
|
|
193
|
+
const SECTION_HEADERS: Record<ActionSection, string | undefined> = {
|
|
194
|
+
start: undefined,
|
|
195
|
+
target: "WRITE NEW SERVERS TO",
|
|
196
|
+
servers: "ADD A SERVER",
|
|
197
|
+
files: "CONFIG FILES",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function actionSection(id: ActionId): ActionSection {
|
|
201
|
+
switch (id) {
|
|
202
|
+
case "run-setup":
|
|
203
|
+
return "start";
|
|
204
|
+
case "select-shared-target":
|
|
205
|
+
return "target";
|
|
206
|
+
case "add-known-server":
|
|
207
|
+
case "add-repoprompt":
|
|
208
|
+
return "servers";
|
|
209
|
+
default:
|
|
210
|
+
return "files";
|
|
211
|
+
}
|
|
86
212
|
}
|
|
87
213
|
|
|
88
214
|
/**
|
|
89
|
-
* The
|
|
90
|
-
*
|
|
215
|
+
* The rendered height depends only on the terminal height, never on the
|
|
216
|
+
* cursor, screen, or notice, so the overlay does not jump while navigating.
|
|
91
217
|
*/
|
|
92
218
|
class McpSetupPanelView implements Component {
|
|
93
219
|
private readonly container = new Container();
|
|
220
|
+
/** Details scroll bounds from the last render, used by the controller to clamp PageUp/PageDown. */
|
|
221
|
+
maxDetailScroll = 0;
|
|
222
|
+
detailPageSize = 1;
|
|
94
223
|
|
|
95
224
|
constructor(
|
|
96
225
|
private readonly getState: () => McpSetupPanelViewState,
|
|
@@ -101,46 +230,40 @@ class McpSetupPanelView implements Component {
|
|
|
101
230
|
render(width: number): string[] {
|
|
102
231
|
const panelWidth = Math.max(MIN_PANEL_WIDTH, width);
|
|
103
232
|
const innerWidth = panelWidth - 2;
|
|
104
|
-
const contentWidth =
|
|
233
|
+
const contentWidth = innerWidth - INSET * 2;
|
|
105
234
|
const state = this.getState();
|
|
235
|
+
const { bodyRows, spacers } = this.layoutRows(state.terminalRows);
|
|
106
236
|
this.container.clear();
|
|
107
237
|
|
|
108
|
-
this.addFrame("
|
|
109
|
-
this.addRow(this.
|
|
110
|
-
|
|
111
|
-
if (!state.discovery.hasAnyConfig || (state.discovery.totalServerCount === 0 && (state.discovery.imports.length > 0 || !!state.discovery.repoPrompt.executablePath))) {
|
|
112
|
-
discoveryTone = this.theme.needsAuth;
|
|
113
|
-
}
|
|
114
|
-
for (const line of wrapText(this.discoverySummaryLine(state), contentWidth)) {
|
|
115
|
-
this.addRow(discoveryTone(line), innerWidth);
|
|
116
|
-
}
|
|
117
|
-
for (const line of wrapText(this.secondarySummaryLine(state), contentWidth)) {
|
|
118
|
-
this.addRow(this.theme.description(this.theme.italic(line)), innerWidth);
|
|
119
|
-
}
|
|
120
|
-
this.addRow("", innerWidth);
|
|
238
|
+
this.addFrame("╭", "╮", "MCP setup");
|
|
239
|
+
this.addRow(this.renderHeader(state, contentWidth), innerWidth);
|
|
240
|
+
if (spacers) this.addRow("", innerWidth);
|
|
121
241
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
242
|
+
const entries = this.listEntries(state);
|
|
243
|
+
let paneRows = bodyRows;
|
|
244
|
+
if (innerWidth >= TWO_PANE_MIN_INNER_WIDTH) {
|
|
245
|
+
const listWidth = Math.max(MIN_LIST_WIDTH, Math.min(MAX_LIST_WIDTH, Math.floor(innerWidth * 0.4)));
|
|
246
|
+
const paneWidth = contentWidth - listWidth - PANE_GUTTER;
|
|
247
|
+
const listLines = this.renderList(entries, bodyRows, listWidth);
|
|
248
|
+
const paneLines = this.renderPane(this.details(state, paneWidth, !spacers), bodyRows, paneWidth, state.detailScroll);
|
|
249
|
+
const rule = this.theme.border("│");
|
|
250
|
+
for (let row = 0; row < bodyRows; row++) {
|
|
251
|
+
this.addRow(`${listLines[row]!} ${rule} ${paneLines[row]!}`, innerWidth);
|
|
131
252
|
}
|
|
132
|
-
|
|
253
|
+
} else {
|
|
254
|
+
const listRows = Math.min(entries.length, Math.max(1, Math.floor((bodyRows - 1) / 2)));
|
|
255
|
+
paneRows = bodyRows - 1 - listRows;
|
|
256
|
+
const paneLines = this.renderPane(this.details(state, contentWidth, true), paneRows, contentWidth, state.detailScroll);
|
|
257
|
+
for (const line of this.renderList(entries, listRows, contentWidth)) this.addRow(line, innerWidth);
|
|
258
|
+
this.addRow(this.theme.border("─".repeat(contentWidth)), innerWidth);
|
|
259
|
+
for (const line of paneLines) this.addRow(line, innerWidth);
|
|
133
260
|
}
|
|
261
|
+
this.detailPageSize = Math.max(1, paneRows - 2);
|
|
134
262
|
|
|
263
|
+
if (spacers) this.addRow("", innerWidth);
|
|
135
264
|
this.addFrame("├", "┤");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
} else if (state.screen === "paths") {
|
|
139
|
-
for (const line of this.renderPaths(state)) this.addRow(line, innerWidth);
|
|
140
|
-
} else {
|
|
141
|
-
for (const line of this.renderActions(state, innerWidth)) this.addRow(line, innerWidth);
|
|
142
|
-
}
|
|
143
|
-
this.addFrame("└", "┘");
|
|
265
|
+
this.addRow(this.renderFooter(state, contentWidth, this.maxDetailScroll > 0), innerWidth);
|
|
266
|
+
this.addFrame("╰", "╯");
|
|
144
267
|
return this.container.render(panelWidth);
|
|
145
268
|
}
|
|
146
269
|
|
|
@@ -148,286 +271,422 @@ class McpSetupPanelView implements Component {
|
|
|
148
271
|
this.container.invalidate();
|
|
149
272
|
}
|
|
150
273
|
|
|
151
|
-
|
|
152
|
-
|
|
274
|
+
/**
|
|
275
|
+
* Splits the terminal's height budget between chrome and body. Short
|
|
276
|
+
* terminals lose the spacer rows first, then body rows, so the borders and
|
|
277
|
+
* hints row always fit down to about 10 terminal rows.
|
|
278
|
+
*/
|
|
279
|
+
private layoutRows(terminalRows: number | undefined): { bodyRows: number; spacers: boolean } {
|
|
280
|
+
if (!terminalRows || terminalRows <= 0) return { bodyRows: MAX_BODY_ROWS, spacers: true };
|
|
281
|
+
const budget = terminalRows - OVERLAY_VERTICAL_MARGIN * 2 - FRAME_ROWS;
|
|
282
|
+
if (budget - SPACER_ROWS >= MIN_SPACED_BODY_ROWS) {
|
|
283
|
+
return { bodyRows: Math.min(MAX_BODY_ROWS, budget - SPACER_ROWS), spacers: true };
|
|
284
|
+
}
|
|
285
|
+
return { bodyRows: Math.max(MIN_BODY_ROWS, budget), spacers: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private addFrame(left: string, right: string, title?: string): void {
|
|
289
|
+
this.container.addChild(new McpPanelFrame(this.theme, left, right, title));
|
|
153
290
|
}
|
|
154
291
|
|
|
155
292
|
private addRow(content: string, innerWidth: number): void {
|
|
156
|
-
|
|
293
|
+
const contentWidth = Math.max(0, innerWidth - INSET * 2);
|
|
294
|
+
const fitted = truncateToWidth(content, contentWidth, "…", true);
|
|
295
|
+
const padding = Math.max(0, contentWidth - visibleWidth(fitted));
|
|
296
|
+
const inset = " ".repeat(INSET);
|
|
297
|
+
const border = this.theme.border("│");
|
|
298
|
+
this.container.addChild(new Text(`${border}${inset}${fitted}${" ".repeat(padding)}${inset}${border}`, 0, 0));
|
|
157
299
|
}
|
|
158
300
|
|
|
159
|
-
private
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
301
|
+
private renderHeader(state: McpSetupPanelViewState, width: number): string {
|
|
302
|
+
const { discovery } = state;
|
|
303
|
+
let status: string;
|
|
304
|
+
let tone = this.theme.hint;
|
|
305
|
+
if (!discovery.hasAnyConfig) {
|
|
306
|
+
status = state.onboardingState.setupCompleted ? "No MCP servers are active right now." : "No MCP config is active yet.";
|
|
307
|
+
tone = this.theme.needsAuth;
|
|
308
|
+
} else if (discovery.totalServerCount === 0 && (discovery.imports.length > 0 || !!discovery.repoPrompt.executablePath)) {
|
|
309
|
+
status = "Pi found MCP-related setup options, but none are active in Pi yet.";
|
|
310
|
+
tone = this.theme.needsAuth;
|
|
311
|
+
} else {
|
|
312
|
+
status = plural(discovery.totalServerCount, "server");
|
|
313
|
+
}
|
|
166
314
|
|
|
167
|
-
|
|
168
|
-
|
|
315
|
+
const extras: string[] = [];
|
|
316
|
+
if (discovery.imports.length > 0) extras.push(plural(discovery.imports.length, "import"));
|
|
317
|
+
if (discovery.hostConfigs.length > 0) extras.push(plural(discovery.hostConfigs.length, "host config"));
|
|
318
|
+
if (discovery.conflicts.length > 0) extras.push(plural(discovery.conflicts.length, "conflict"));
|
|
319
|
+
const summary = extras.join(" · ");
|
|
320
|
+
const statusWidth = visibleWidth(status);
|
|
321
|
+
const summaryWidth = visibleWidth(summary);
|
|
322
|
+
if (!summary || statusWidth + 2 + summaryWidth > width) {
|
|
323
|
+
return tone(fitText(status, width));
|
|
169
324
|
}
|
|
170
|
-
|
|
171
|
-
const action = actions[index];
|
|
172
|
-
if (!action) continue;
|
|
173
|
-
if (action.id === "select-shared-target" && (index === start || actions[index - 1]?.id !== "select-shared-target")) {
|
|
174
|
-
lines.push(this.theme.title("Choose where new shared servers go"));
|
|
175
|
-
}
|
|
176
|
-
if (action.id === "add-known-server" && (index === start || actions[index - 1]?.id !== "add-known-server")) {
|
|
177
|
-
lines.push(this.theme.title(`Add a known server to ${this.sharedTargetLabel(state)}`));
|
|
178
|
-
}
|
|
179
|
-
const selected = index === state.actionCursor;
|
|
180
|
-
const cursor = selected ? this.theme.selected("›") : " ";
|
|
181
|
-
lines.push(`${cursor} ${truncateToWidth(action.label, this.contentWidth(innerWidth) - 2)}`);
|
|
182
|
-
}
|
|
183
|
-
if (end < actions.length) {
|
|
184
|
-
lines.push(this.theme.description(`… ${actions.length - end} more below`));
|
|
185
|
-
}
|
|
186
|
-
lines.push("");
|
|
187
|
-
|
|
188
|
-
const previewWidth = this.previewWidth(innerWidth);
|
|
189
|
-
lines.push(...this.previewOrError(
|
|
190
|
-
() => this.getActionPreview(state, actions[state.actionCursor], previewWidth),
|
|
191
|
-
previewWidth,
|
|
192
|
-
));
|
|
193
|
-
lines.push("");
|
|
194
|
-
const hint = compact ? "Enter select · Esc back" : "Enter selects, Esc goes back, Ctrl+C closes.";
|
|
195
|
-
lines.push(this.theme.description(hint));
|
|
196
|
-
return lines;
|
|
325
|
+
return `${tone(status)}${" ".repeat(width - statusWidth - summaryWidth)}${this.theme.hint(summary)}`;
|
|
197
326
|
}
|
|
198
327
|
|
|
199
|
-
private
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
328
|
+
private renderFooter(state: McpSetupPanelViewState, width: number, detailsOverflow: boolean): string {
|
|
329
|
+
const busy = "Working…";
|
|
330
|
+
const hintsWidth = state.busy ? width - visibleWidth(busy) - 2 : width;
|
|
331
|
+
let screenHints = "↑↓ move · enter select · esc close";
|
|
332
|
+
if (state.screen === "imports") screenHints = "↑↓ move · space toggle · enter save · esc back";
|
|
333
|
+
else if (state.screen === "paths") screenHints = "↑↓ move · enter open · esc back";
|
|
334
|
+
// Scroll keys lead so narrow footers cut the other hints, not the only way to reach hidden details.
|
|
335
|
+
const keyHints = `${detailsOverflow ? "pgup/pgdn scroll · " : ""}${screenHints}`;
|
|
336
|
+
const hints = fitText(keyHints, Math.max(0, hintsWidth));
|
|
337
|
+
if (!state.busy) return this.theme.hint(hints);
|
|
338
|
+
const gap = Math.max(2, width - visibleWidth(hints) - visibleWidth(busy));
|
|
339
|
+
return `${this.theme.hint(hints)}${" ".repeat(gap)}${this.theme.hint(busy)}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private listEntries(state: McpSetupPanelViewState): ListEntry[] {
|
|
343
|
+
if (state.screen === "imports") {
|
|
344
|
+
const imports = state.discovery.imports;
|
|
345
|
+
const kindWidth = Math.max(0, ...imports.map((entry) => entry.kind.length));
|
|
346
|
+
return [
|
|
347
|
+
{ kind: "header", text: "ADOPT IMPORTS FROM" },
|
|
348
|
+
...imports.map((entry, index): ListEntry => ({
|
|
349
|
+
kind: "item",
|
|
350
|
+
label: `${state.selectedImports.has(entry.kind) ? "[x]" : "[ ]"} ${entry.kind.padEnd(kindWidth)}`,
|
|
351
|
+
detail: shortenPath(entry.path),
|
|
352
|
+
selected: index === state.importCursor,
|
|
353
|
+
})),
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
if (state.screen === "paths") {
|
|
357
|
+
return [
|
|
358
|
+
{ kind: "header", text: "OPEN A CONFIG FILE" },
|
|
359
|
+
...state.detectedPaths.map((path, index): ListEntry => ({
|
|
360
|
+
kind: "item",
|
|
361
|
+
label: shortenPath(path),
|
|
362
|
+
selected: index === state.pathCursor,
|
|
363
|
+
})),
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const entries: ListEntry[] = [];
|
|
368
|
+
let section: ActionSection | undefined;
|
|
369
|
+
state.actions.forEach((action, index) => {
|
|
370
|
+
const next = actionSection(action.id);
|
|
371
|
+
if (next !== section) {
|
|
372
|
+
if (section !== undefined) entries.push({ kind: "blank" });
|
|
373
|
+
const header = SECTION_HEADERS[next];
|
|
374
|
+
if (header) entries.push({ kind: "header", text: header });
|
|
375
|
+
section = next;
|
|
376
|
+
}
|
|
377
|
+
entries.push({ kind: "item", label: action.label, detail: action.detail, selected: index === state.actionCursor });
|
|
378
|
+
});
|
|
379
|
+
return entries;
|
|
220
380
|
}
|
|
221
381
|
|
|
222
|
-
|
|
382
|
+
/**
|
|
383
|
+
* Renders exactly `rows` list lines, scrolling to keep the selected item
|
|
384
|
+
* visible. `↑/↓ N more` rows need at least 3 rows so they never cover the cursor.
|
|
385
|
+
*/
|
|
386
|
+
private renderList(entries: ListEntry[], rows: number, width: number): string[] {
|
|
387
|
+
let start = 0;
|
|
388
|
+
if (entries.length > rows) {
|
|
389
|
+
const cursor = Math.max(0, entries.findIndex((entry) => entry.kind === "item" && entry.selected));
|
|
390
|
+
start = Math.max(0, Math.min(cursor - Math.floor(rows / 2), entries.length - rows));
|
|
391
|
+
}
|
|
392
|
+
const end = Math.min(entries.length, start + rows);
|
|
393
|
+
const hiddenItems = (from: number, to: number) => entries.slice(from, to).filter((entry) => entry.kind === "item").length;
|
|
394
|
+
const indicators = rows >= 3;
|
|
223
395
|
const lines: string[] = [];
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
396
|
+
for (let index = start; index < end; index++) {
|
|
397
|
+
if (indicators && index === start && start > 0) {
|
|
398
|
+
lines.push(this.moreLine("↑", hiddenItems(0, start + 1), width));
|
|
399
|
+
} else if (indicators && index === end - 1 && end < entries.length) {
|
|
400
|
+
lines.push(this.moreLine("↓", hiddenItems(end - 1, entries.length), width));
|
|
401
|
+
} else {
|
|
402
|
+
lines.push(this.renderEntry(entries[index]!, width));
|
|
403
|
+
}
|
|
230
404
|
}
|
|
405
|
+
while (lines.length < rows) lines.push(" ".repeat(width));
|
|
231
406
|
return lines;
|
|
232
407
|
}
|
|
233
408
|
|
|
234
|
-
private
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
: "No MCP config is active yet.";
|
|
239
|
-
}
|
|
409
|
+
private moreLine(arrow: string, count: number, width: number): string {
|
|
410
|
+
const text = count > 0 ? ` ${arrow} ${count} more` : "";
|
|
411
|
+
return this.theme.hint(fitText(text, width, true));
|
|
412
|
+
}
|
|
240
413
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
414
|
+
private renderEntry(entry: ListEntry, width: number): string {
|
|
415
|
+
if (entry.kind === "blank") return " ".repeat(width);
|
|
416
|
+
if (entry.kind === "header") return this.theme.description(fitText(entry.text, width, true));
|
|
244
417
|
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
418
|
+
const cursor = entry.selected ? this.theme.selected("›") : " ";
|
|
419
|
+
const available = width - 2;
|
|
420
|
+
const label = fitText(entry.label, available);
|
|
421
|
+
const styledLabel = entry.selected ? this.theme.selected(this.theme.bold(label)) : label;
|
|
422
|
+
let line = `${cursor} ${styledLabel}`;
|
|
423
|
+
let used = 2 + visibleWidth(label);
|
|
424
|
+
const detailRoom = available - visibleWidth(label) - 2;
|
|
425
|
+
if (entry.detail && detailRoom >= 4) {
|
|
426
|
+
const detail = fitText(entry.detail, detailRoom);
|
|
427
|
+
line += ` ${this.theme.description(detail)}`;
|
|
428
|
+
used += 2 + visibleWidth(detail);
|
|
429
|
+
}
|
|
430
|
+
return `${line}${" ".repeat(Math.max(0, width - used))}`;
|
|
248
431
|
}
|
|
249
432
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
433
|
+
/**
|
|
434
|
+
* Renders exactly `rows` details lines starting `scroll` lines down. When the
|
|
435
|
+
* content doesn't fit and the pane has at least 3 rows, the first and last
|
|
436
|
+
* rows become muted `↑ N more` / `↓ N more` markers, the same as the list.
|
|
437
|
+
* Smaller panes show content only; the footer hint says they scroll.
|
|
438
|
+
*/
|
|
439
|
+
private renderPane(content: PaneLine[], rows: number, width: number, scroll: number): string[] {
|
|
440
|
+
const lines = [...content];
|
|
441
|
+
while (lines.length > 0 && !lines[lines.length - 1]!.text.trim()) lines.pop();
|
|
442
|
+
this.maxDetailScroll = Math.max(0, lines.length - rows);
|
|
443
|
+
const start = Math.max(0, Math.min(scroll, this.maxDetailScroll));
|
|
444
|
+
const end = Math.min(lines.length, start + rows);
|
|
445
|
+
const shown = lines.slice(start, end);
|
|
446
|
+
if (rows >= 3 && start > 0) {
|
|
447
|
+
shown[0] = { text: `↑ ${start + 1} more`, tone: this.theme.hint };
|
|
259
448
|
}
|
|
260
|
-
if (
|
|
261
|
-
|
|
449
|
+
if (rows >= 3 && end < lines.length) {
|
|
450
|
+
shown[shown.length - 1] = { text: `↓ ${lines.length - end + 1} more`, tone: this.theme.hint };
|
|
262
451
|
}
|
|
263
|
-
|
|
452
|
+
const rendered = shown.map((line) => {
|
|
453
|
+
const fitted = fitText(line.text, width);
|
|
454
|
+
const styled = line.tone ? line.tone(fitted) : fitted;
|
|
455
|
+
return `${styled}${" ".repeat(Math.max(0, width - visibleWidth(fitted)))}`;
|
|
456
|
+
});
|
|
457
|
+
while (rendered.length < rows) rendered.push(" ".repeat(width));
|
|
458
|
+
return rendered;
|
|
264
459
|
}
|
|
265
460
|
|
|
266
|
-
private
|
|
267
|
-
|
|
268
|
-
const half = Math.floor(COMPACT_ACTION_ROWS / 2);
|
|
269
|
-
const start = Math.min(Math.max(0, cursor - half), Math.max(0, total - COMPACT_ACTION_ROWS));
|
|
270
|
-
return { start, end: Math.min(total, start + COMPACT_ACTION_ROWS) };
|
|
461
|
+
private heading(text: string): PaneLine[] {
|
|
462
|
+
return [{ text, tone: (value) => this.theme.selected(this.theme.bold(value)), heading: true }, { text: "" }];
|
|
271
463
|
}
|
|
272
464
|
|
|
273
|
-
private
|
|
274
|
-
return
|
|
465
|
+
private prose(width: number, ...paragraphs: string[]): PaneLine[] {
|
|
466
|
+
return paragraphs.flatMap((paragraph) => wrapIndented(paragraph, width).map((text) => ({ text })));
|
|
275
467
|
}
|
|
276
468
|
|
|
277
|
-
private
|
|
278
|
-
return
|
|
469
|
+
private muted(text: string): PaneLine {
|
|
470
|
+
return { text, tone: this.theme.description };
|
|
279
471
|
}
|
|
280
472
|
|
|
281
|
-
|
|
282
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Details pane content, most urgent first: the notice, then any preview
|
|
475
|
+
* error, then the highlighted item, so short panes still show what matters.
|
|
476
|
+
* `compact` (stacked or short layouts) drops the blank row under the heading.
|
|
477
|
+
*/
|
|
478
|
+
private details(state: McpSetupPanelViewState, width: number, compact: boolean): PaneLine[] {
|
|
479
|
+
const errors: PaneLine[] = [];
|
|
480
|
+
let body: PaneLine[];
|
|
481
|
+
if (state.screen === "imports") body = this.importDetails(state, width, errors);
|
|
482
|
+
else if (state.screen === "paths") body = this.pathDetails(state, width);
|
|
483
|
+
else body = this.actionDetails(state, state.actions[state.actionCursor], width, errors);
|
|
484
|
+
if (compact && body[0]?.heading && body[1]?.text === "") body.splice(1, 1);
|
|
485
|
+
|
|
486
|
+
const lines: PaneLine[] = [];
|
|
487
|
+
const notice = state.busy ? null : state.notice;
|
|
488
|
+
if (notice) {
|
|
489
|
+
let tone = this.theme.hint;
|
|
490
|
+
if (notice.tone === "success") tone = this.theme.confirm;
|
|
491
|
+
else if (notice.tone === "warning") tone = this.theme.needsAuth;
|
|
492
|
+
lines.push(...wrapIndented(notice.text, width).map((text) => ({ text, tone })), { text: "" });
|
|
493
|
+
}
|
|
494
|
+
if (errors.length > 0) lines.push(...errors, { text: "" });
|
|
495
|
+
return [...lines, ...body];
|
|
283
496
|
}
|
|
284
497
|
|
|
285
|
-
private
|
|
498
|
+
private importDetails(state: McpSetupPanelViewState, width: number, errors: PaneLine[]): PaneLine[] {
|
|
499
|
+
const selected = state.discovery.imports
|
|
500
|
+
.filter((entry) => state.selectedImports.has(entry.kind))
|
|
501
|
+
.map((entry) => entry.kind);
|
|
502
|
+
return [
|
|
503
|
+
...this.heading("Adopt compatibility imports"),
|
|
504
|
+
...this.prose(width, "Space toggles an import. Enter writes the selected imports to mcp-adapter.json in the Pi agent dir."),
|
|
505
|
+
{ text: "" },
|
|
506
|
+
this.muted(`${selected.length} of ${state.discovery.imports.length} selected`),
|
|
507
|
+
...this.writePreview(() => this.callbacks.previewImports(selected), width, errors),
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private pathDetails(state: McpSetupPanelViewState, width: number): PaneLine[] {
|
|
512
|
+
const path = state.detectedPaths[state.pathCursor];
|
|
513
|
+
if (path === undefined) return this.prose(width, "No config paths were detected.");
|
|
514
|
+
return [
|
|
515
|
+
...this.heading(basename(path)),
|
|
516
|
+
...hardWrap(shortenPath(path), width).map((text) => this.muted(text)),
|
|
517
|
+
{ text: "" },
|
|
518
|
+
...this.prose(width, "Enter opens this file with your system's default app."),
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private actionDetails(state: McpSetupPanelViewState, action: Action | undefined, width: number, errors: PaneLine[]): PaneLine[] {
|
|
523
|
+
const { discovery } = state;
|
|
286
524
|
switch (action?.id) {
|
|
287
525
|
case "run-setup":
|
|
288
|
-
return
|
|
289
|
-
"Run setup
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
case "select-shared-target":
|
|
304
|
-
return this.formatPreview([
|
|
305
|
-
action.target === "project" ? "Project target: .mcp.json" : "Global target: ~/.config/mcp/mcp.json",
|
|
306
|
-
"Known server presets and starter configs will be written to the selected normal MCP setup path.",
|
|
307
|
-
], previewW);
|
|
308
|
-
case "view-example":
|
|
309
|
-
return this.formatPreview([
|
|
310
|
-
"Example shared `.mcp.json`:",
|
|
311
|
-
"{",
|
|
312
|
-
' "mcpServers": {',
|
|
313
|
-
' "chrome-devtools": {',
|
|
314
|
-
' "command": "npx",',
|
|
315
|
-
' "args": ["-y", "chrome-devtools-mcp@1.6.0"]',
|
|
316
|
-
" }",
|
|
317
|
-
" }",
|
|
318
|
-
"}",
|
|
319
|
-
"",
|
|
320
|
-
"Use Scaffold selected config when you want a safe empty shell instead of a live example server.",
|
|
321
|
-
], previewW);
|
|
322
|
-
case "show-precedence":
|
|
323
|
-
return this.formatPreview([
|
|
324
|
-
"Recommended shared config:",
|
|
325
|
-
" project/team: .mcp.json",
|
|
326
|
-
" all projects: ~/.config/mcp/mcp.json",
|
|
327
|
-
"",
|
|
328
|
-
"Advanced compatibility and adapter-owned layers:",
|
|
329
|
-
" host imports, .agents files, package MCP manifests, and Pi overrides",
|
|
330
|
-
"",
|
|
331
|
-
"Read order (later entries win):",
|
|
332
|
-
"0. detected host configs (opt-in lowest-precedence fallback)",
|
|
333
|
-
"1. ~/.config/mcp/mcp.json",
|
|
334
|
-
"2. ~/.agents/mcp.json",
|
|
335
|
-
"3. ~/.agents/mcp/mcp.json",
|
|
336
|
-
"4. <Pi agent dir>/mcp-adapter.json",
|
|
337
|
-
"5. configured ancestor root to parent(cwd), farthest first (opt-in)",
|
|
338
|
-
` per directory: .mcp.json, then ${getConfigDirName()}/mcp-adapter.json`,
|
|
339
|
-
"6. cwd/.mcp.json",
|
|
340
|
-
`7. cwd/${getConfigDirName()}/mcp-adapter.json`,
|
|
341
|
-
`Host discovery: ${state.discovery.hostConfigDiscovery}. Conflicts reported: ${state.discovery.conflicts.length}.`,
|
|
342
|
-
...state.discovery.conflicts.slice(0, 8).map((conflict) =>
|
|
343
|
-
`${conflict.serverName}: ${conflict.sources.map((source) => source.path).join(" -> ")} (winner: ${conflict.winner.path})`,
|
|
526
|
+
return [
|
|
527
|
+
...this.heading("Run setup"),
|
|
528
|
+
...this.prose(width, "Adopt host-specific imports, inspect detected paths, and scaffold a minimal .mcp.json if needed."),
|
|
529
|
+
];
|
|
530
|
+
case "select-shared-target": {
|
|
531
|
+
const project = action.target === "project";
|
|
532
|
+
return [
|
|
533
|
+
...this.heading(project ? "Project config" : "Global config"),
|
|
534
|
+
this.muted(project ? ".mcp.json" : "~/.config/mcp/mcp.json"),
|
|
535
|
+
{ text: "" },
|
|
536
|
+
...this.prose(
|
|
537
|
+
width,
|
|
538
|
+
project
|
|
539
|
+
? "Servers here load in this project only. Commit the file to share them with your team."
|
|
540
|
+
: "Servers here load in every project on this machine.",
|
|
344
541
|
),
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
: ["No config paths were detected."], previewW);
|
|
351
|
-
case "add-repoprompt": {
|
|
352
|
-
const repoPrompt = state.discovery.repoPrompt;
|
|
353
|
-
const preview = this.callbacks.previewRepoPrompt(state.sharedConfigTarget);
|
|
354
|
-
if (!preview) {
|
|
355
|
-
return this.formatPreview(["RepoPrompt is not available to add from this setup screen."], previewW);
|
|
356
|
-
}
|
|
357
|
-
return this.formatWritePreview(
|
|
358
|
-
"RepoPrompt write preview",
|
|
359
|
-
preview,
|
|
360
|
-
[
|
|
361
|
-
`Executable: ${repoPrompt.executablePath ?? "not found"}`,
|
|
362
|
-
`Target: ${this.sharedTargetLabel(state)}`,
|
|
363
|
-
`Server name: ${repoPrompt.serverName ?? "repoprompt"}`,
|
|
364
|
-
],
|
|
365
|
-
previewW,
|
|
366
|
-
);
|
|
542
|
+
{ text: "" },
|
|
543
|
+
...this.prose(width, "Known servers and starter configs are written to the selected file."),
|
|
544
|
+
{ text: "" },
|
|
545
|
+
this.muted(state.sharedConfigTarget === action.target ? "Selected." : "Press enter to write new servers here."),
|
|
546
|
+
];
|
|
367
547
|
}
|
|
368
548
|
case "add-known-server": {
|
|
369
549
|
const preset = action.preset;
|
|
370
|
-
if (!preset) return this.
|
|
371
|
-
return
|
|
372
|
-
|
|
373
|
-
this.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
550
|
+
if (!preset) return this.prose(width, "Known server preset is unavailable.");
|
|
551
|
+
return [
|
|
552
|
+
...this.heading(preset.name),
|
|
553
|
+
...this.prose(width, preset.summary),
|
|
554
|
+
...(preset.desktopApp ? [{ text: "" }, ...this.prose(width, preset.desktopApp.enableSteps)] : []),
|
|
555
|
+
...this.writePreview(() => this.callbacks.previewKnownServer(preset, state.sharedConfigTarget), width, errors),
|
|
556
|
+
];
|
|
557
|
+
}
|
|
558
|
+
case "add-repoprompt": {
|
|
559
|
+
const repoPrompt = discovery.repoPrompt;
|
|
560
|
+
const lines = [
|
|
561
|
+
...this.heading("RepoPrompt"),
|
|
562
|
+
...this.prose(width, "Adds the RepoPrompt MCP server installed on this machine."),
|
|
563
|
+
{ text: "" },
|
|
564
|
+
this.muted(`Executable ${repoPrompt.executablePath ? shortenPath(repoPrompt.executablePath) : "not found"}`),
|
|
565
|
+
this.muted(`Server name ${repoPrompt.serverName ?? "repoprompt"}`),
|
|
566
|
+
];
|
|
567
|
+
const preview = this.previewOrError(() => this.callbacks.previewRepoPrompt(state.sharedConfigTarget), width, errors);
|
|
568
|
+
if (preview === null) return [...lines, { text: "" }, ...this.prose(width, "RepoPrompt is not available to add from this setup screen.")];
|
|
569
|
+
return [...lines, ...preview];
|
|
570
|
+
}
|
|
571
|
+
case "adopt-imports": {
|
|
572
|
+
const selected = discovery.imports
|
|
573
|
+
.filter((entry) => state.selectedImports.has(entry.kind))
|
|
574
|
+
.map((entry) => entry.kind);
|
|
575
|
+
return [
|
|
576
|
+
...this.heading("Adopt compatibility imports"),
|
|
577
|
+
...this.prose(
|
|
578
|
+
width,
|
|
579
|
+
`Detected: ${discovery.imports.map((entry) => `${entry.kind} (${plural(entry.serverCount, "server")})`).join(", ")}.`,
|
|
580
|
+
"Selected imports are written to mcp-adapter.json in the Pi agent dir as adapter-owned compatibility state.",
|
|
581
|
+
),
|
|
582
|
+
...this.writePreview(() => this.callbacks.previewImports(selected), width, errors),
|
|
583
|
+
];
|
|
377
584
|
}
|
|
378
585
|
case "scaffold-shared-config":
|
|
379
|
-
return
|
|
380
|
-
|
|
381
|
-
this.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
586
|
+
return [
|
|
587
|
+
...this.heading(action.label),
|
|
588
|
+
...this.prose(width, "Writes a minimal config with no servers, so nothing fails on the first reload."),
|
|
589
|
+
...this.writePreview(() => this.callbacks.previewStarterConfig(state.sharedConfigTarget), width, errors),
|
|
590
|
+
];
|
|
591
|
+
case "view-example":
|
|
592
|
+
return [
|
|
593
|
+
...this.heading("Example config"),
|
|
594
|
+
...this.prose(width, "A shared .mcp.json with one server:"),
|
|
595
|
+
{ text: "" },
|
|
596
|
+
...[
|
|
597
|
+
"{",
|
|
598
|
+
' "mcpServers": {',
|
|
599
|
+
' "chrome-devtools": {',
|
|
600
|
+
' "command": "npx",',
|
|
601
|
+
' "args": ["-y", "chrome-devtools-mcp@1.6.0"]',
|
|
602
|
+
" }",
|
|
603
|
+
" }",
|
|
604
|
+
"}",
|
|
605
|
+
].map((text) => this.muted(text)),
|
|
606
|
+
{ text: "" },
|
|
607
|
+
...this.prose(width, "Scaffold writes an empty config instead when you don't want a live example server."),
|
|
608
|
+
];
|
|
609
|
+
case "show-precedence":
|
|
610
|
+
return [
|
|
611
|
+
...this.heading("Config precedence"),
|
|
612
|
+
this.muted([
|
|
613
|
+
`Host discovery: ${discovery.hostConfigDiscovery}`,
|
|
614
|
+
...(discovery.hostConfigs.length > 0 ? [plural(discovery.hostConfigs.length, "host config")] : []),
|
|
615
|
+
plural(discovery.conflicts.length, "conflict"),
|
|
616
|
+
].join(" · ")),
|
|
617
|
+
...discovery.conflicts.slice(0, 8).flatMap((conflict) => this.prose(
|
|
618
|
+
width,
|
|
619
|
+
`- ${conflict.serverName}: ${conflict.sources.map((source) => shortenPath(source.path)).join(" -> ")} (winner: ${shortenPath(conflict.winner.path)})`,
|
|
620
|
+
).map((line) => ({ ...line, tone: this.theme.needsAuth }))),
|
|
621
|
+
{ text: "" },
|
|
622
|
+
...this.prose(width, "Recommended shared config:", " project/team: .mcp.json", " all projects: ~/.config/mcp/mcp.json"),
|
|
623
|
+
{ text: "" },
|
|
624
|
+
...this.prose(
|
|
625
|
+
width,
|
|
626
|
+
"Read order (later entries win):",
|
|
627
|
+
"0. detected host configs (opt-in lowest-precedence fallback)",
|
|
628
|
+
"1. ~/.config/mcp/mcp.json",
|
|
629
|
+
"2. ~/.agents/mcp.json",
|
|
630
|
+
"3. ~/.agents/mcp/mcp.json",
|
|
631
|
+
"4. <Pi agent dir>/mcp-adapter.json",
|
|
632
|
+
"5. configured ancestor root to parent(cwd), farthest first (opt-in)",
|
|
633
|
+
` per directory: .mcp.json, then ${getConfigDirName()}/mcp-adapter.json`,
|
|
634
|
+
"6. cwd/.mcp.json",
|
|
635
|
+
`7. cwd/${getConfigDirName()}/mcp-adapter.json`,
|
|
636
|
+
),
|
|
637
|
+
{ text: "" },
|
|
638
|
+
...this.prose(
|
|
639
|
+
width,
|
|
640
|
+
"Advanced compatibility and adapter-owned layers:",
|
|
641
|
+
" host imports, .agents files, package MCP manifests, and Pi overrides",
|
|
642
|
+
"mcp-adapter.json files are for compatibility imports and adapter-specific overrides; Pi mcp.json files are not read by the adapter.",
|
|
643
|
+
),
|
|
644
|
+
];
|
|
645
|
+
case "open-paths":
|
|
646
|
+
return [
|
|
647
|
+
...this.heading("Open config files"),
|
|
648
|
+
...this.prose(width, "Press enter to pick a detected config file and open it."),
|
|
649
|
+
{ text: "" },
|
|
650
|
+
...state.detectedPaths.map((path) => this.muted(shortenPath(path))),
|
|
651
|
+
];
|
|
389
652
|
default:
|
|
390
|
-
return
|
|
653
|
+
return [];
|
|
391
654
|
}
|
|
392
655
|
}
|
|
393
656
|
|
|
394
|
-
private
|
|
395
|
-
|
|
396
|
-
for (const line of lines) preview.push(...wrapText(line, width));
|
|
397
|
-
return preview;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
private previewOrError(renderPreview: () => string[], width: number): string[] {
|
|
657
|
+
private previewOrError(getPreview: () => ConfigWritePreview | null, width: number, errors: PaneLine[]): PaneLine[] | null {
|
|
658
|
+
let preview: ConfigWritePreview | null;
|
|
401
659
|
try {
|
|
402
|
-
|
|
660
|
+
preview = getPreview();
|
|
403
661
|
} catch (error) {
|
|
404
|
-
|
|
662
|
+
errors.push(
|
|
663
|
+
{ text: "Preview unavailable:", tone: this.theme.needsAuth },
|
|
664
|
+
...this.prose(width, error instanceof Error ? error.message : String(error))
|
|
665
|
+
.map((line) => ({ ...line, tone: this.theme.needsAuth })),
|
|
666
|
+
);
|
|
667
|
+
return [];
|
|
405
668
|
}
|
|
669
|
+
return preview ? this.formatWritePreview(preview) : null;
|
|
406
670
|
}
|
|
407
671
|
|
|
408
|
-
private
|
|
409
|
-
|
|
410
|
-
for (const line of intro) lines.push(...wrapText(line, width));
|
|
411
|
-
if (intro.length > 0) lines.push("");
|
|
412
|
-
lines.push(...wrapText(`${title}: ${preview.path}`, width));
|
|
413
|
-
lines.push(...wrapText(preview.existed ? "Existing file detected. Showing exact before/after diff." : "New file will be created. Showing exact content diff.", width));
|
|
414
|
-
lines.push("");
|
|
415
|
-
const diffLines = preview.diffText.split("\n");
|
|
416
|
-
const maxLines = 18;
|
|
417
|
-
const shown = diffLines.slice(0, maxLines);
|
|
418
|
-
for (const line of shown) lines.push(...wrapText(line, width));
|
|
419
|
-
if (diffLines.length > maxLines) {
|
|
420
|
-
lines.push(...wrapText(`… ${diffLines.length - maxLines} more diff line${diffLines.length - maxLines === 1 ? "" : "s"}`, width));
|
|
421
|
-
}
|
|
422
|
-
return lines;
|
|
672
|
+
private writePreview(getPreview: () => ConfigWritePreview, width: number, errors: PaneLine[]): PaneLine[] {
|
|
673
|
+
return this.previewOrError(getPreview, width, errors) ?? [];
|
|
423
674
|
}
|
|
424
675
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
676
|
+
/** Diff lines are never word-wrapped: each keeps its indentation and is cut with … at the pane edge. */
|
|
677
|
+
private formatWritePreview(preview: ConfigWritePreview): PaneLine[] {
|
|
678
|
+
const path = shortenPath(preview.path);
|
|
679
|
+
if (preview.existed && !preview.changed) return [{ text: "" }, this.muted(`No changes to ${path}`)];
|
|
680
|
+
const lines: PaneLine[] = [{ text: "" }, this.muted(`${preview.existed ? "Updates" : "Creates"} ${path}`)];
|
|
681
|
+
const diffLines = preview.diffText.split("\n").filter((line) => line !== "--- before" && line !== "+++ after");
|
|
682
|
+
while (diffLines.length > 0 && !diffLines[diffLines.length - 1]!.trim()) diffLines.pop();
|
|
683
|
+
for (const line of diffLines) {
|
|
684
|
+
let tone = this.theme.description;
|
|
685
|
+
if (line.startsWith("+")) tone = this.theme.confirm;
|
|
686
|
+
else if (line.startsWith("-")) tone = this.theme.cancel;
|
|
687
|
+
lines.push({ text: line, tone });
|
|
688
|
+
}
|
|
689
|
+
return lines;
|
|
431
690
|
}
|
|
432
691
|
}
|
|
433
692
|
|
|
@@ -439,10 +698,10 @@ export class McpSetupPanel {
|
|
|
439
698
|
private sharedConfigTarget: SharedConfigTarget = "project";
|
|
440
699
|
private selectedImports = new Set<ImportKind>();
|
|
441
700
|
private busy = false;
|
|
442
|
-
private
|
|
443
|
-
private
|
|
701
|
+
private detailScroll = 0;
|
|
702
|
+
private notice: Notice | null = null;
|
|
444
703
|
private readonly view: McpSetupPanelView;
|
|
445
|
-
private keys: PanelKeys;
|
|
704
|
+
private readonly keys: PanelKeys;
|
|
446
705
|
private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
447
706
|
private static readonly INACTIVITY_MS = 60_000;
|
|
448
707
|
|
|
@@ -450,10 +709,9 @@ export class McpSetupPanel {
|
|
|
450
709
|
private discovery: McpDiscoverySummary,
|
|
451
710
|
private callbacks: SetupPanelCallbacks,
|
|
452
711
|
private options: SetupPanelOptions,
|
|
453
|
-
|
|
712
|
+
private readonly tui: SetupPanelTui,
|
|
454
713
|
private done: () => void,
|
|
455
714
|
) {
|
|
456
|
-
this.tui = tui;
|
|
457
715
|
this.keys = createPanelKeys(options.keybindings);
|
|
458
716
|
this.view = new McpSetupPanelView(() => this.getViewState(), callbacks, createMcpPanelTheme(options.theme));
|
|
459
717
|
this.screen = options.mode;
|
|
@@ -483,28 +741,27 @@ export class McpSetupPanel {
|
|
|
483
741
|
if (this.screen === "empty") {
|
|
484
742
|
actions.push({ id: "run-setup", label: "Run setup" });
|
|
485
743
|
}
|
|
486
|
-
if (this.discovery.imports.length > 0) {
|
|
487
|
-
actions.push({ id: "adopt-imports", label: "Adopt detected compatibility imports" });
|
|
488
|
-
}
|
|
489
744
|
actions.push(
|
|
490
|
-
{ id: "select-shared-target", label: `${this.sharedConfigTarget === "project" ? "●" : "○"}
|
|
491
|
-
{ id: "select-shared-target", label: `${this.sharedConfigTarget === "global" ? "●" : "○"}
|
|
745
|
+
{ id: "select-shared-target", label: `${this.sharedConfigTarget === "project" ? "●" : "○"} Project`, detail: ".mcp.json", target: "project" },
|
|
746
|
+
{ id: "select-shared-target", label: `${this.sharedConfigTarget === "global" ? "●" : "○"} Global `, detail: "~/.config/mcp/mcp.json", target: "global" },
|
|
492
747
|
);
|
|
493
|
-
|
|
494
|
-
if (!this.selectedSharedConfigExists()) {
|
|
495
|
-
actions.push({ id: "scaffold-shared-config", label: `Scaffold ${this.sharedTargetLabel()}` });
|
|
496
|
-
}
|
|
497
|
-
actions.push({ id: "show-precedence", label: "Explain config precedence" });
|
|
498
|
-
if (this.getDetectedPaths().length > 0) {
|
|
499
|
-
actions.push({ id: "open-paths", label: "Open detected config paths" });
|
|
500
|
-
}
|
|
501
|
-
for (const preset of KNOWN_SERVER_PRESETS) {
|
|
748
|
+
for (const preset of this.discovery.knownServerPresets) {
|
|
502
749
|
actions.push({ id: "add-known-server", label: preset.name, preset });
|
|
503
750
|
}
|
|
504
751
|
if (!this.discovery.repoPrompt.configured && this.discovery.repoPrompt.executablePath && this.discovery.repoPrompt.targetPath && this.discovery.repoPrompt.entry && this.discovery.repoPrompt.serverName) {
|
|
505
|
-
actions.push({ id: "add-repoprompt", label: "
|
|
752
|
+
actions.push({ id: "add-repoprompt", label: "RepoPrompt" });
|
|
753
|
+
}
|
|
754
|
+
if (this.discovery.imports.length > 0) {
|
|
755
|
+
actions.push({ id: "adopt-imports", label: "Adopt compatibility imports" });
|
|
756
|
+
}
|
|
757
|
+
if (!this.selectedSharedConfigExists()) {
|
|
758
|
+
actions.push({ id: "scaffold-shared-config", label: `Scaffold ${this.sharedConfigTarget === "project" ? ".mcp.json" : "~/.config/mcp/mcp.json"}` });
|
|
759
|
+
}
|
|
760
|
+
actions.push({ id: "view-example", label: "Example config" });
|
|
761
|
+
actions.push({ id: "show-precedence", label: "Config precedence" });
|
|
762
|
+
if (this.getDetectedPaths().length > 0) {
|
|
763
|
+
actions.push({ id: "open-paths", label: "Open config files" });
|
|
506
764
|
}
|
|
507
|
-
actions.push({ id: "close", label: "Close" });
|
|
508
765
|
return actions;
|
|
509
766
|
}
|
|
510
767
|
|
|
@@ -527,6 +784,9 @@ export class McpSetupPanel {
|
|
|
527
784
|
|
|
528
785
|
handleInput(data: string): void {
|
|
529
786
|
this.resetInactivityTimeout();
|
|
787
|
+
// Scrolling keeps the notice, cursor, and screen; any other key resets the scroll.
|
|
788
|
+
if (this.handleDetailScroll(data)) return;
|
|
789
|
+
this.detailScroll = 0;
|
|
530
790
|
if (!this.busy) this.notice = null;
|
|
531
791
|
|
|
532
792
|
if (matchesKey(data, "ctrl+c")) {
|
|
@@ -574,6 +834,21 @@ export class McpSetupPanel {
|
|
|
574
834
|
}
|
|
575
835
|
}
|
|
576
836
|
|
|
837
|
+
private handleDetailScroll(data: string): boolean {
|
|
838
|
+
let delta = 0;
|
|
839
|
+
if (matchesKey(data, "pageDown")) delta = this.view.detailPageSize;
|
|
840
|
+
else if (matchesKey(data, "pageUp")) delta = -this.view.detailPageSize;
|
|
841
|
+
else if (matchesKey(data, "shift+down")) delta = 1;
|
|
842
|
+
else if (matchesKey(data, "shift+up")) delta = -1;
|
|
843
|
+
else return false;
|
|
844
|
+
const next = Math.max(0, Math.min(this.view.maxDetailScroll, this.detailScroll + delta));
|
|
845
|
+
if (next !== this.detailScroll) {
|
|
846
|
+
this.detailScroll = next;
|
|
847
|
+
this.tui.requestRender();
|
|
848
|
+
}
|
|
849
|
+
return true;
|
|
850
|
+
}
|
|
851
|
+
|
|
577
852
|
private handleImportsInput(data: string): void {
|
|
578
853
|
const imports = this.discovery.imports;
|
|
579
854
|
if (this.keys.selectUp(data)) {
|
|
@@ -670,17 +945,20 @@ export class McpSetupPanel {
|
|
|
670
945
|
await this.runBusy(async () => {
|
|
671
946
|
const result = await this.callbacks.addKnownServer(preset, this.sharedConfigTarget);
|
|
672
947
|
this.callbacks.markSetupCompleted();
|
|
673
|
-
|
|
948
|
+
let status = "";
|
|
949
|
+
if (preset.desktopApp && result.reachable !== undefined) {
|
|
950
|
+
status = result.reachable
|
|
951
|
+
? ` A server is answering at ${preset.entry.url}.`
|
|
952
|
+
: ` Nothing is answering at ${preset.entry.url} yet. ${preset.desktopApp.enableSteps}`;
|
|
953
|
+
}
|
|
954
|
+
this.notice = {
|
|
955
|
+
text: `Added ${result.serverName} to ${result.path}.${status} Pi will reload after this panel closes.`,
|
|
956
|
+
tone: result.reachable === false ? "warning" : "success",
|
|
957
|
+
};
|
|
674
958
|
});
|
|
675
959
|
return;
|
|
676
960
|
}
|
|
677
|
-
|
|
678
|
-
this.cleanup();
|
|
679
|
-
this.done();
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
this.notice = { text: "Review the details below. Press Enter on an action with a side effect to apply it.", tone: "muted" };
|
|
961
|
+
this.notice = { text: "Review the details. Press Enter on an action with a side effect to apply it.", tone: "muted" };
|
|
684
962
|
this.tui.requestRender();
|
|
685
963
|
}
|
|
686
964
|
|
|
@@ -705,7 +983,6 @@ export class McpSetupPanel {
|
|
|
705
983
|
|
|
706
984
|
private async runBusy(fn: () => Promise<void>): Promise<void> {
|
|
707
985
|
this.busy = true;
|
|
708
|
-
this.notice = { text: "Working...", tone: "muted" };
|
|
709
986
|
this.tui.requestRender();
|
|
710
987
|
try {
|
|
711
988
|
await fn();
|
|
@@ -716,6 +993,7 @@ export class McpSetupPanel {
|
|
|
716
993
|
};
|
|
717
994
|
} finally {
|
|
718
995
|
this.busy = false;
|
|
996
|
+
this.detailScroll = 0;
|
|
719
997
|
this.tui.requestRender();
|
|
720
998
|
}
|
|
721
999
|
}
|
|
@@ -729,10 +1007,13 @@ export class McpSetupPanel {
|
|
|
729
1007
|
sharedConfigTarget: this.sharedConfigTarget,
|
|
730
1008
|
selectedImports: this.selectedImports,
|
|
731
1009
|
notice: this.notice,
|
|
1010
|
+
busy: this.busy,
|
|
1011
|
+
detailScroll: this.detailScroll,
|
|
732
1012
|
onboardingState: this.options.onboardingState,
|
|
733
1013
|
discovery: this.discovery,
|
|
734
1014
|
actions: this.getActions(),
|
|
735
1015
|
detectedPaths: this.getDetectedPaths(),
|
|
1016
|
+
terminalRows: this.tui.terminal?.rows,
|
|
736
1017
|
};
|
|
737
1018
|
}
|
|
738
1019
|
|
|
@@ -753,7 +1034,7 @@ export function createMcpSetupPanel(
|
|
|
753
1034
|
discovery: McpDiscoverySummary,
|
|
754
1035
|
callbacks: SetupPanelCallbacks,
|
|
755
1036
|
options: SetupPanelOptions,
|
|
756
|
-
tui:
|
|
1037
|
+
tui: SetupPanelTui,
|
|
757
1038
|
done: () => void,
|
|
758
1039
|
): McpSetupPanel & { dispose(): void } {
|
|
759
1040
|
return new McpSetupPanel(discovery, callbacks, options, tui, done);
|