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.
Files changed (70) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/OAUTH.md +4 -2
  3. package/README.md +72 -10
  4. package/cli.js +1 -0
  5. package/commands.ts +20 -2
  6. package/config.ts +19 -1
  7. package/direct-tool-surface.ts +3 -2
  8. package/direct-tools.ts +12 -2
  9. package/dist/config.d.ts +6 -0
  10. package/dist/config.js +15 -0
  11. package/dist/config.js.map +1 -1
  12. package/dist/elicitation-handler.d.ts +9 -1
  13. package/dist/elicitation-handler.js +65 -2
  14. package/dist/elicitation-handler.js.map +1 -1
  15. package/dist/jev-client.js +7 -5
  16. package/dist/jev-client.js.map +1 -1
  17. package/dist/jev-key-store.d.ts +1 -0
  18. package/dist/jev-key-store.js +11 -0
  19. package/dist/jev-key-store.js.map +1 -1
  20. package/dist/mcp-auth-flow.d.ts +2 -0
  21. package/dist/mcp-auth-flow.js +12 -3
  22. package/dist/mcp-auth-flow.js.map +1 -1
  23. package/dist/metadata-cache.d.ts +12 -0
  24. package/dist/metadata-cache.js +50 -6
  25. package/dist/metadata-cache.js.map +1 -1
  26. package/dist/output-shape.d.ts +36 -0
  27. package/dist/output-shape.js +338 -0
  28. package/dist/output-shape.js.map +1 -0
  29. package/dist/package-mcp-loader.js +9 -5
  30. package/dist/package-mcp-loader.js.map +1 -1
  31. package/dist/server-manager.js +27 -16
  32. package/dist/server-manager.js.map +1 -1
  33. package/dist/state.d.ts +6 -0
  34. package/dist/ts-shape.d.ts +3 -0
  35. package/dist/ts-shape.js +153 -0
  36. package/dist/ts-shape.js.map +1 -0
  37. package/dist/types.d.ts +24 -3
  38. package/dist/types.js.map +1 -1
  39. package/dist/unix-socket-transport.js +1 -1
  40. package/dist/unix-socket-transport.js.map +1 -1
  41. package/dist/utils.d.ts +6 -1
  42. package/dist/utils.js +17 -0
  43. package/dist/utils.js.map +1 -1
  44. package/elicitation-handler.ts +78 -1
  45. package/index.ts +72 -12
  46. package/init.ts +20 -5
  47. package/jev-client.ts +7 -4
  48. package/jev-key-store.ts +7 -0
  49. package/mcp-auth-flow.ts +13 -2
  50. package/mcp-code.ts +126 -68
  51. package/mcp-output-guard.ts +24 -5
  52. package/mcp-script-worker.mjs +6 -2
  53. package/mcp-setup-panel.ts +583 -302
  54. package/metadata-cache.ts +60 -5
  55. package/output-shape.ts +348 -0
  56. package/package-mcp-loader.ts +8 -5
  57. package/package.json +6 -6
  58. package/prompts.ts +68 -0
  59. package/proxy-modes.ts +149 -106
  60. package/server-manager.ts +28 -15
  61. package/skills/mcp-scripting/SKILL.md +24 -28
  62. package/skills/mcp-scripting/references/jev.md +3 -0
  63. package/state.ts +6 -0
  64. package/tool-approval.ts +17 -4
  65. package/tool-metadata.ts +3 -1
  66. package/ts-shape.ts +1 -1
  67. package/types.ts +22 -3
  68. package/ui-server.ts +5 -2
  69. package/unix-socket-transport.ts +1 -1
  70. package/utils.ts +16 -1
@@ -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 { KNOWN_SERVER_PRESETS, type ConfigWritePreview, type KnownServerPreset, type McpDiscoverySummary, type SharedConfigTarget } from "./config.ts";
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
- const COMPACT_WIDTH = 60;
12
- const COMPACT_ACTION_ROWS = 7;
13
- const DESKTOP_PREVIEW_WIDTH = 74;
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: { text: string; tone: "success" | "warning" | "muted" } | null;
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 setup view owns component composition and display formatting. The panel
90
- * below remains the controller for input routing, async actions, and timers.
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 = this.contentWidth(innerWidth);
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.theme.title("MCP setup"), innerWidth);
110
- let discoveryTone = this.theme.hint;
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
- if (state.notice) {
123
- let tone = this.theme.hint;
124
- if (state.notice.tone === "success") {
125
- tone = this.theme.confirm;
126
- } else if (state.notice.tone === "warning") {
127
- tone = this.theme.needsAuth;
128
- }
129
- for (const line of wrapText(state.notice.text, contentWidth)) {
130
- this.addRow(tone(line), innerWidth);
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
- this.addRow("", innerWidth);
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
- if (state.screen === "imports") {
137
- for (const line of this.renderImports(state, innerWidth)) this.addRow(line, innerWidth);
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
- private addFrame(left: string, right: string): void {
152
- this.container.addChild(new McpPanelFrame(this.theme, left, right));
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
- this.container.addChild(new Text(this.padLine(content, innerWidth), 0, 0));
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 renderActions(state: McpSetupPanelViewState, innerWidth: number): string[] {
160
- const lines: string[] = [];
161
- const actions = state.actions;
162
- const compact = innerWidth < COMPACT_WIDTH;
163
- const { start, end } = compact
164
- ? this.visibleActionRange(actions.length, state.actionCursor)
165
- : { start: 0, end: actions.length };
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
- if (start > 0) {
168
- lines.push(this.theme.description(`… ${start} more above`));
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
- for (let index = start; index < end; index++) {
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 renderImports(state: McpSetupPanelViewState, innerWidth: number): string[] {
200
- const lines: string[] = [];
201
- lines.push("Select compatibility imports. Space toggles, Enter saves, Esc goes back.");
202
- lines.push("");
203
- for (let index = 0; index < state.discovery.imports.length; index++) {
204
- const entry = state.discovery.imports[index];
205
- if (!entry) continue;
206
- const selected = state.selectedImports.has(entry.kind) ? "[x]" : "[ ]";
207
- const cursor = index === state.importCursor ? this.theme.selected("›") : " ";
208
- lines.push(`${cursor} ${selected} ${entry.kind} ${entry.path}`);
209
- }
210
- lines.push("");
211
- const selected = state.discovery.imports
212
- .filter((entry) => state.selectedImports.has(entry.kind))
213
- .map((entry) => entry.kind);
214
- const previewWidth = this.previewWidth(innerWidth);
215
- lines.push(...this.previewOrError(
216
- () => this.formatWritePreview("Compatibility import write preview", this.callbacks.previewImports(selected), [], previewWidth),
217
- previewWidth,
218
- ));
219
- return lines;
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
- private renderPaths(state: McpSetupPanelViewState): string[] {
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
- lines.push("Select a detected config path to open. Enter opens it, Esc goes back.");
225
- lines.push("");
226
- for (let index = 0; index < state.detectedPaths.length; index++) {
227
- const path = state.detectedPaths[index];
228
- const cursor = index === state.pathCursor ? this.theme.selected("›") : " ";
229
- if (path !== undefined) lines.push(`${cursor} ${path}`);
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 discoverySummaryLine(state: McpSetupPanelViewState): string {
235
- if (!state.discovery.hasAnyConfig) {
236
- return state.onboardingState.setupCompleted
237
- ? "No MCP servers are active right now."
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
- if (state.discovery.totalServerCount === 0 && (state.discovery.imports.length > 0 || !!state.discovery.repoPrompt.executablePath)) {
242
- return "Pi found MCP-related setup options, but none are active in Pi yet.";
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 shared = state.discovery.sources.filter((source) => source.kind === "shared" && source.serverCount > 0).length;
246
- const piOwned = state.discovery.sources.filter((source) => source.kind === "pi" && source.serverCount > 0).length;
247
- return `Detected ${state.discovery.totalServerCount} configured servers across ${shared} shared and ${piOwned} adapter-owned source${shared + piOwned === 1 ? "" : "s"}.`;
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
- private secondarySummaryLine(state: McpSetupPanelViewState): string {
251
- const hostNote = state.discovery.hostConfigs.length > 0
252
- ? ` Host discovery is ${state.discovery.hostConfigDiscovery}; ${state.discovery.hostConfigs.length} host source${state.discovery.hostConfigs.length === 1 ? "" : "s"} detected.`
253
- : "";
254
- const conflictNote = state.discovery.conflicts.length > 0
255
- ? ` ${state.discovery.conflicts.length} same-name conflict${state.discovery.conflicts.length === 1 ? "" : "s"} reported.`
256
- : "";
257
- if (!state.discovery.hasAnyConfig) {
258
- return `Add shared servers to .mcp.json for this project/team or ~/.config/mcp/mcp.json for all projects. Adopt host imports or quick-add RepoPrompt from this screen.${hostNote}${conflictNote}`;
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 (state.discovery.totalServerCount === 0 && state.discovery.imports.length > 0) {
261
- return `Detected ${state.discovery.imports.length} compatibility import source${state.discovery.imports.length === 1 ? "" : "s"}. Adopt them into the adapter or inspect the underlying files.${hostNote}${conflictNote}`;
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
- return `Use .mcp.json for project/team servers or ~/.config/mcp/mcp.json for all projects. mcp-adapter.json files are for compatibility imports and adapter-specific overrides; Pi mcp.json files are not read by the adapter.${hostNote}${conflictNote}`;
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 visibleActionRange(total: number, cursor: number): { start: number; end: number } {
267
- if (total <= COMPACT_ACTION_ROWS) return { start: 0, end: total };
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 contentWidth(innerWidth: number): number {
274
- return Math.max(8, innerWidth - 4);
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 previewWidth(innerWidth: number): number {
278
- return Math.max(12, Math.min(DESKTOP_PREVIEW_WIDTH, this.contentWidth(innerWidth)));
469
+ private muted(text: string): PaneLine {
470
+ return { text, tone: this.theme.description };
279
471
  }
280
472
 
281
- private sharedTargetLabel(state: McpSetupPanelViewState): string {
282
- return state.sharedConfigTarget === "project" ? "project .mcp.json" : "global ~/.config/mcp/mcp.json";
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 getActionPreview(state: McpSetupPanelViewState, action?: Action, previewW = DESKTOP_PREVIEW_WIDTH): string[] {
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 this.formatPreview([
289
- "Run setup to adopt host-specific imports, inspect detected paths, and scaffold a minimal `.mcp.json` if needed.",
290
- ], previewW);
291
- case "adopt-imports":
292
- return this.formatWritePreview(
293
- "Compatibility import write preview",
294
- this.callbacks.previewImports(state.discovery.imports
295
- .filter((entry) => state.selectedImports.has(entry.kind))
296
- .map((entry) => entry.kind)),
297
- [
298
- `Detected imports: ${state.discovery.imports.map((entry) => `${entry.kind} (${entry.serverCount} servers)`).join(", ")}`,
299
- "Selected imports are written into the Pi agent dir mcp-adapter.json as adapter-owned compatibility state.",
300
- ],
301
- previewW,
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
- "The adapter writes compatibility imports and adapter-only overrides to mcp-adapter.json files.",
346
- ], previewW);
347
- case "open-paths":
348
- return this.formatPreview(state.detectedPaths.length > 0
349
- ? ["Detected paths:", ...state.detectedPaths]
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.formatPreview(["Known server preset is unavailable."], previewW);
371
- return this.formatWritePreview(
372
- `${preset.name} write preview`,
373
- this.callbacks.previewKnownServer(preset, state.sharedConfigTarget),
374
- [preset.summary, `Target: ${this.sharedTargetLabel(state)}`],
375
- previewW,
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 this.formatWritePreview(
380
- `${this.sharedTargetLabel(state)} starter write preview`,
381
- this.callbacks.previewStarterConfig(state.sharedConfigTarget),
382
- [
383
- "This writes a minimal config at the selected normal MCP setup path.",
384
- "It intentionally avoids adding a fake placeholder server that would fail on first reload.",
385
- ],
386
- previewW,
387
- );
388
- case "close":
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 this.formatPreview(["Close the setup flow."], previewW);
653
+ return [];
391
654
  }
392
655
  }
393
656
 
394
- private formatPreview(lines: string[], width = DESKTOP_PREVIEW_WIDTH): string[] {
395
- const preview: string[] = [];
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
- return renderPreview();
660
+ preview = getPreview();
403
661
  } catch (error) {
404
- return this.formatPreview([`Preview unavailable: ${error instanceof Error ? error.message : String(error)}`], width);
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 formatWritePreview(title: string, preview: ConfigWritePreview, intro: string[] = [], width = DESKTOP_PREVIEW_WIDTH): string[] {
409
- const lines: string[] = [];
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
- private padLine(text: string, innerWidth: number): string {
426
- const inset = 2;
427
- const contentWidth = Math.max(0, innerWidth - inset * 2);
428
- const fitted = truncateToWidth(text, contentWidth, "…", true);
429
- const padding = Math.max(0, contentWidth - visibleWidth(fitted));
430
- return `${this.theme.border("│")}${" ".repeat(inset)}${fitted}${" ".repeat(padding)}${" ".repeat(inset)}${this.theme.border("│")}`;
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 notice: { text: string; tone: "success" | "warning" | "muted" } | null = null;
443
- private tui: { requestRender(): void };
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
- tui: { requestRender(): void },
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" ? "●" : "○"} Add to this project (.mcp.json)`, target: "project" },
491
- { id: "select-shared-target", label: `${this.sharedConfigTarget === "global" ? "●" : "○"} Add globally (~/.config/mcp/mcp.json)`, target: "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
- actions.push({ id: "view-example", label: "View example shared config" });
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: "Add RepoPrompt to selected shared config" });
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
- this.notice = { text: `Added ${result.serverName} to ${result.path}. Pi will reload after this panel closes.`, tone: "success" };
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
- if (action.id === "close") {
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: { requestRender(): void },
1037
+ tui: SetupPanelTui,
757
1038
  done: () => void,
758
1039
  ): McpSetupPanel & { dispose(): void } {
759
1040
  return new McpSetupPanel(discovery, callbacks, options, tui, done);