@fitchmultz/pi-mcp-adapter 5.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 (176) hide show
  1. package/CHANGELOG.md +750 -0
  2. package/LICENSE +21 -0
  3. package/OAUTH.md +462 -0
  4. package/README.md +820 -0
  5. package/abort.ts +34 -0
  6. package/agent-dir.ts +24 -0
  7. package/app-bridge.bundle.js +67 -0
  8. package/banner.png +0 -0
  9. package/checkpoint.ts +55 -0
  10. package/cli.js +222 -0
  11. package/commands.ts +619 -0
  12. package/config.ts +1204 -0
  13. package/consent-manager.ts +25 -0
  14. package/direct-tools.ts +408 -0
  15. package/dist/abort.d.ts +2 -0
  16. package/dist/abort.js +35 -0
  17. package/dist/agent-dir.d.ts +3 -0
  18. package/dist/agent-dir.js +21 -0
  19. package/dist/app-bridge.bundle.js +67 -0
  20. package/dist/checkpoint.d.ts +18 -0
  21. package/dist/checkpoint.js +53 -0
  22. package/dist/commands.d.ts +20 -0
  23. package/dist/commands.js +521 -0
  24. package/dist/config.d.ts +109 -0
  25. package/dist/config.js +1013 -0
  26. package/dist/consent-manager.d.ts +8 -0
  27. package/dist/consent-manager.js +21 -0
  28. package/dist/direct-tools.d.ts +10 -0
  29. package/dist/direct-tools.js +350 -0
  30. package/dist/docs/client-metadata.json +14 -0
  31. package/dist/elicitation-handler.d.ts +17 -0
  32. package/dist/elicitation-handler.js +308 -0
  33. package/dist/error-signal.d.ts +15 -0
  34. package/dist/error-signal.js +21 -0
  35. package/dist/glimpse-ui.d.ts +9 -0
  36. package/dist/glimpse-ui.js +75 -0
  37. package/dist/host-html-template.d.ts +15 -0
  38. package/dist/host-html-template.js +429 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +840 -0
  41. package/dist/init.d.ts +25 -0
  42. package/dist/init.js +562 -0
  43. package/dist/legacy-migration.d.ts +18 -0
  44. package/dist/legacy-migration.js +45 -0
  45. package/dist/lifecycle.d.ts +40 -0
  46. package/dist/lifecycle.js +167 -0
  47. package/dist/logger.d.ts +38 -0
  48. package/dist/logger.js +102 -0
  49. package/dist/mcp-auth-flow.d.ts +113 -0
  50. package/dist/mcp-auth-flow.js +834 -0
  51. package/dist/mcp-auth.d.ts +127 -0
  52. package/dist/mcp-auth.js +658 -0
  53. package/dist/mcp-callback-server.d.ts +56 -0
  54. package/dist/mcp-callback-server.js +371 -0
  55. package/dist/mcp-code.d.ts +51 -0
  56. package/dist/mcp-code.js +366 -0
  57. package/dist/mcp-keyring-helper.cjs +89 -0
  58. package/dist/mcp-oauth-provider.d.ts +154 -0
  59. package/dist/mcp-oauth-provider.js +672 -0
  60. package/dist/mcp-output-guard.d.ts +59 -0
  61. package/dist/mcp-output-guard.js +332 -0
  62. package/dist/mcp-panel.d.ts +66 -0
  63. package/dist/mcp-panel.js +889 -0
  64. package/dist/mcp-script-worker.mjs +128 -0
  65. package/dist/mcp-setup-panel.d.ts +84 -0
  66. package/dist/mcp-setup-panel.js +561 -0
  67. package/dist/mcp-status.d.ts +11 -0
  68. package/dist/mcp-status.js +96 -0
  69. package/dist/mcp-trace.d.ts +95 -0
  70. package/dist/mcp-trace.js +233 -0
  71. package/dist/metadata-cache.d.ts +18 -0
  72. package/dist/metadata-cache.js +277 -0
  73. package/dist/npx-resolver.d.ts +6 -0
  74. package/dist/npx-resolver.js +448 -0
  75. package/dist/onboarding-state.d.ts +11 -0
  76. package/dist/onboarding-state.js +47 -0
  77. package/dist/panel-keys.d.ts +16 -0
  78. package/dist/panel-keys.js +15 -0
  79. package/dist/prompts.d.ts +56 -0
  80. package/dist/prompts.js +284 -0
  81. package/dist/proxy-modes.d.ts +38 -0
  82. package/dist/proxy-modes.js +1235 -0
  83. package/dist/resource-tools.d.ts +1 -0
  84. package/dist/resource-tools.js +14 -0
  85. package/dist/runtime-owner.d.ts +18 -0
  86. package/dist/runtime-owner.js +117 -0
  87. package/dist/sampling-handler.d.ts +15 -0
  88. package/dist/sampling-handler.js +211 -0
  89. package/dist/search-ranking.d.ts +20 -0
  90. package/dist/search-ranking.js +180 -0
  91. package/dist/server-manager.d.ts +114 -0
  92. package/dist/server-manager.js +1175 -0
  93. package/dist/session-recovery.d.ts +31 -0
  94. package/dist/session-recovery.js +310 -0
  95. package/dist/state.d.ts +63 -0
  96. package/dist/state.js +1 -0
  97. package/dist/tool-approval.d.ts +10 -0
  98. package/dist/tool-approval.js +42 -0
  99. package/dist/tool-metadata.d.ts +9 -0
  100. package/dist/tool-metadata.js +193 -0
  101. package/dist/tool-registrar.d.ts +10 -0
  102. package/dist/tool-registrar.js +64 -0
  103. package/dist/tool-result-renderer.d.ts +45 -0
  104. package/dist/tool-result-renderer.js +224 -0
  105. package/dist/ts-shape.d.ts +2 -0
  106. package/dist/ts-shape.js +147 -0
  107. package/dist/types.d.ts +516 -0
  108. package/dist/types.js +168 -0
  109. package/dist/ui-resource-handler.d.ts +17 -0
  110. package/dist/ui-resource-handler.js +209 -0
  111. package/dist/ui-server.d.ts +51 -0
  112. package/dist/ui-server.js +669 -0
  113. package/dist/ui-session.d.ts +39 -0
  114. package/dist/ui-session.js +463 -0
  115. package/dist/ui-stream-types.d.ts +104 -0
  116. package/dist/ui-stream-types.js +58 -0
  117. package/dist/ui-tool-visibility.d.ts +4 -0
  118. package/dist/ui-tool-visibility.js +26 -0
  119. package/dist/unix-socket-transport.d.ts +15 -0
  120. package/dist/unix-socket-transport.js +84 -0
  121. package/dist/utils.d.ts +30 -0
  122. package/dist/utils.js +276 -0
  123. package/docs/client-metadata.json +14 -0
  124. package/elicitation-handler.ts +349 -0
  125. package/error-signal.ts +21 -0
  126. package/glimpse-ui.ts +80 -0
  127. package/host-html-template.ts +456 -0
  128. package/index.ts +900 -0
  129. package/init.ts +627 -0
  130. package/legacy-migration.ts +47 -0
  131. package/lifecycle.ts +165 -0
  132. package/logger.ts +127 -0
  133. package/mcp-auth-flow.ts +975 -0
  134. package/mcp-auth.ts +793 -0
  135. package/mcp-callback-server.ts +450 -0
  136. package/mcp-code.ts +386 -0
  137. package/mcp-keyring-helper.cjs +89 -0
  138. package/mcp-oauth-provider.ts +774 -0
  139. package/mcp-output-guard.ts +412 -0
  140. package/mcp-panel.ts +978 -0
  141. package/mcp-script-worker.mjs +128 -0
  142. package/mcp-setup-panel.ts +666 -0
  143. package/mcp-status.ts +109 -0
  144. package/mcp-trace.ts +295 -0
  145. package/metadata-cache.ts +333 -0
  146. package/npx-resolver.ts +488 -0
  147. package/onboarding-state.ts +58 -0
  148. package/package.json +182 -0
  149. package/panel-keys.ts +37 -0
  150. package/prompts.ts +337 -0
  151. package/proxy-modes.ts +1361 -0
  152. package/resource-tools.ts +17 -0
  153. package/runtime-owner.ts +123 -0
  154. package/sampling-handler.ts +273 -0
  155. package/scripts/build.mjs +131 -0
  156. package/scripts/prepare.mjs +102 -0
  157. package/search-ranking.ts +196 -0
  158. package/server-manager.ts +1320 -0
  159. package/session-recovery.ts +316 -0
  160. package/skills/mcp-scripting/SKILL.md +38 -0
  161. package/state.ts +64 -0
  162. package/tool-approval.ts +78 -0
  163. package/tool-metadata.ts +235 -0
  164. package/tool-registrar.ts +69 -0
  165. package/tool-result-renderer.ts +286 -0
  166. package/ts-shape.ts +145 -0
  167. package/tsconfig.build.json +13 -0
  168. package/tsconfig.json +16 -0
  169. package/types.ts +810 -0
  170. package/ui-resource-handler.ts +249 -0
  171. package/ui-server.ts +808 -0
  172. package/ui-session.ts +568 -0
  173. package/ui-stream-types.ts +89 -0
  174. package/ui-tool-visibility.ts +25 -0
  175. package/unix-socket-transport.ts +85 -0
  176. package/utils.ts +309 -0
package/mcp-panel.ts ADDED
@@ -0,0 +1,978 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { copyToClipboard } from "@earendil-works/pi-coding-agent";
3
+ import { createPanelKeys, type PanelKeybindings, type PanelKeys } from "./panel-keys.ts";
4
+ import { isServerDisabled, isToolAllowed } from "./types.ts";
5
+ import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance, ToolPrefix } from "./types.ts";
6
+ import { resourceNameToToolName } from "./resource-tools.ts";
7
+ import { sanitizeTerminalText, stripOscSequences } from "./utils.ts";
8
+ import type { MetadataCache, ServerCacheEntry, CachedTool } from "./metadata-cache.ts";
9
+
10
+ interface PanelTheme {
11
+ border: string;
12
+ title: string;
13
+ selected: string;
14
+ direct: string;
15
+ needsAuth: string;
16
+ placeholder: string;
17
+ description: string;
18
+ hint: string;
19
+ confirm: string;
20
+ cancel: string;
21
+ }
22
+
23
+ const DEFAULT_THEME: PanelTheme = {
24
+ border: "2",
25
+ title: "2",
26
+ selected: "36",
27
+ direct: "32",
28
+ needsAuth: "33",
29
+ placeholder: "2;3",
30
+ description: "2",
31
+ hint: "2",
32
+ confirm: "32",
33
+ cancel: "31",
34
+ };
35
+
36
+ function fg(code: string, text: string): string {
37
+ if (!code) return text;
38
+ return `\x1b[${code}m${text}\x1b[0m`;
39
+ }
40
+
41
+ const RAINBOW_COLORS = [
42
+ "38;2;178;129;214",
43
+ "38;2;215;135;175",
44
+ "38;2;254;188;56",
45
+ "38;2;228;192;15",
46
+ "38;2;137;210;129",
47
+ "38;2;0;175;175",
48
+ "38;2;23;143;185",
49
+ ];
50
+
51
+ function rainbowProgress(filled: number, total: number): string {
52
+ const dots: string[] = [];
53
+ for (let i = 0; i < total; i++) {
54
+ const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length];
55
+ if (!color) continue;
56
+ dots.push(fg(color, i < filled ? "●" : "○"));
57
+ }
58
+ return dots.join(" ");
59
+ }
60
+
61
+ function fuzzyScore(query: string, text: string): number {
62
+ const lq = query.toLowerCase();
63
+ const lt = text.toLowerCase();
64
+ if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
65
+ let score = 0;
66
+ let qi = 0;
67
+ let consecutive = 0;
68
+ for (let i = 0; i < lt.length && qi < lq.length; i++) {
69
+ if (lt[i] === lq[qi]) {
70
+ score += 10 + consecutive;
71
+ consecutive += 5;
72
+ qi++;
73
+ } else {
74
+ consecutive = 0;
75
+ }
76
+ }
77
+ return qi === lq.length ? score : 0;
78
+ }
79
+
80
+ function sanitizeDisplayText(text: string | null | undefined): string {
81
+ return sanitizeTerminalText(text ?? "");
82
+ }
83
+
84
+ function sanitizeRowContent(content: string): string {
85
+ const withoutOsc = stripOscSequences(content);
86
+ let result = "";
87
+ let pendingSpace = false;
88
+ for (let i = 0; i < withoutOsc.length; i++) {
89
+ const rest = withoutOsc.slice(i);
90
+ const ansi = rest.match(/^(?:\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_])/);
91
+ if (ansi) {
92
+ result += ansi[0];
93
+ i += ansi[0].length - 1;
94
+ continue;
95
+ }
96
+
97
+ const code = withoutOsc.charCodeAt(i);
98
+ if (code <= 0x1f || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
99
+ pendingSpace = true;
100
+ continue;
101
+ }
102
+
103
+ if (pendingSpace && result && !result.endsWith(" ")) {
104
+ result += " ";
105
+ }
106
+ pendingSpace = false;
107
+ result += withoutOsc[i];
108
+ }
109
+ return result;
110
+ }
111
+
112
+ function estimateTokens(tool: CachedTool): number {
113
+ const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
114
+ const descLen = tool.description?.length ?? 0;
115
+ return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
116
+ }
117
+
118
+ type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting" | "disabled";
119
+
120
+ interface ToolState {
121
+ name: string;
122
+ description: string;
123
+ isDirect: boolean;
124
+ wasDirect: boolean;
125
+ estimatedTokens: number;
126
+ }
127
+
128
+ interface ServerState {
129
+ name: string;
130
+ expanded: boolean;
131
+ source: "user" | "project" | "import";
132
+ importKind?: string;
133
+ includeTools?: string[];
134
+ excludeTools?: string[];
135
+ exposeResources: boolean;
136
+ connectionStatus: ConnectionStatus;
137
+ failureMessage?: string | null;
138
+ tools: ToolState[];
139
+ hasCachedData: boolean;
140
+ }
141
+
142
+ interface VisibleItem {
143
+ type: "server" | "tool";
144
+ serverIndex: number;
145
+ toolIndex?: number;
146
+ }
147
+
148
+ class McpPanel {
149
+ private noticeLines: string[];
150
+ private prefix: ToolPrefix;
151
+ private servers: ServerState[] = [];
152
+ private cursorIndex = 0;
153
+ private nameQuery = "";
154
+ private descSearchActive = false;
155
+ private descQuery = "";
156
+ private dirty = false;
157
+ private confirmingDiscard = false;
158
+ private discardSelected = 1;
159
+ private importNotice: string | null = null;
160
+ private authNotice: string | null = null;
161
+ private authInFlight: string | null = null;
162
+ private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
163
+ private visibleItems: VisibleItem[] = [];
164
+ private tui: { requestRender(): void };
165
+ private t = DEFAULT_THEME;
166
+ private authOnly: boolean;
167
+ private keys: PanelKeys;
168
+
169
+ private static readonly MAX_VISIBLE = 12;
170
+ private static readonly INACTIVITY_MS = 60_000;
171
+
172
+ constructor(
173
+ config: McpConfig,
174
+ cache: MetadataCache | null,
175
+ provenance: Map<string, ServerProvenance>,
176
+ private callbacks: McpPanelCallbacks,
177
+ tui: { requestRender(): void },
178
+ private done: (result: McpPanelResult) => void,
179
+ options: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings } = {},
180
+ ) {
181
+ this.tui = tui;
182
+ this.noticeLines = options.noticeLines ?? [];
183
+ this.authOnly = options.authOnly === true;
184
+ this.keys = createPanelKeys(options.keybindings);
185
+ this.prefix = config.settings?.toolPrefix ?? "server";
186
+
187
+ for (const [serverName, definition] of Object.entries(config.mcpServers)) {
188
+ if (this.authOnly && !callbacks.canAuthenticate(serverName)) continue;
189
+ const prov = provenance.get(serverName);
190
+ const serverCache = cache?.servers?.[serverName];
191
+
192
+ const globalDirect = config.settings?.directTools;
193
+ let toolFilter: true | string[] | false = false;
194
+ if (definition.directTools !== undefined) {
195
+ toolFilter = definition.directTools;
196
+ } else if (globalDirect) {
197
+ toolFilter = globalDirect;
198
+ }
199
+
200
+ const tools: ToolState[] = [];
201
+ if (serverCache && !this.authOnly && !isServerDisabled(definition)) {
202
+ for (const tool of serverCache.tools ?? []) {
203
+ if (!isToolAllowed(tool.name, serverName, this.prefix, definition.includeTools, definition.excludeTools)) {
204
+ continue;
205
+ }
206
+
207
+ const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
208
+ tools.push({
209
+ name: tool.name,
210
+ description: tool.description ?? "",
211
+ isDirect,
212
+ wasDirect: isDirect,
213
+ estimatedTokens: estimateTokens(tool),
214
+ });
215
+ }
216
+ if (definition.exposeResources !== false) {
217
+ for (const resource of serverCache.resources ?? []) {
218
+ const baseName = `read_${resourceNameToToolName(resource.name)}`;
219
+ if (!isToolAllowed(baseName, serverName, this.prefix, definition.includeTools, definition.excludeTools)) {
220
+ continue;
221
+ }
222
+
223
+ const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName));
224
+ const ct: CachedTool = {
225
+ name: baseName,
226
+ ...(resource.description !== undefined ? { description: resource.description } : {}),
227
+ };
228
+ tools.push({
229
+ name: baseName,
230
+ description: resource.description ?? `Read resource: ${resource.uri}`,
231
+ isDirect,
232
+ wasDirect: isDirect,
233
+ estimatedTokens: estimateTokens(ct),
234
+ });
235
+ }
236
+ }
237
+ }
238
+
239
+ const status = callbacks.getConnectionStatus(serverName);
240
+ const failureMessage = callbacks.getFailureMessage?.(serverName) ?? null;
241
+
242
+ this.servers.push({
243
+ name: serverName,
244
+ expanded: false,
245
+ source: prov?.kind ?? "user",
246
+ ...(prov?.importKind !== undefined ? { importKind: prov.importKind } : {}),
247
+ ...(definition.includeTools !== undefined ? { includeTools: definition.includeTools } : {}),
248
+ ...(definition.excludeTools !== undefined ? { excludeTools: definition.excludeTools } : {}),
249
+ exposeResources: definition.exposeResources !== false,
250
+ connectionStatus: status,
251
+ failureMessage,
252
+ tools,
253
+ hasCachedData: !!serverCache,
254
+ });
255
+ }
256
+
257
+ this.rebuildVisibleItems();
258
+ this.resetInactivityTimeout();
259
+ }
260
+
261
+ private resetInactivityTimeout(): void {
262
+ if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
263
+ this.inactivityTimeout = setTimeout(() => {
264
+ this.cleanup();
265
+ this.done({ cancelled: true, changes: new Map() });
266
+ }, McpPanel.INACTIVITY_MS);
267
+ }
268
+
269
+ private cleanup(): void {
270
+ if (this.inactivityTimeout) {
271
+ clearTimeout(this.inactivityTimeout);
272
+ this.inactivityTimeout = null;
273
+ }
274
+ }
275
+
276
+ private rebuildVisibleItems(): void {
277
+ const query = this.descSearchActive ? this.descQuery : this.nameQuery;
278
+ const mode = this.descSearchActive ? "desc" : "name";
279
+
280
+ this.visibleItems = [];
281
+ for (let si = 0; si < this.servers.length; si++) {
282
+ const server = this.servers[si];
283
+ if (!server) continue;
284
+ if (query && this.authOnly) {
285
+ const score = mode === "name" ? fuzzyScore(query, server.name) : 0;
286
+ if (score > 0) {
287
+ this.visibleItems.push({ type: "server", serverIndex: si });
288
+ }
289
+ continue;
290
+ }
291
+
292
+ this.visibleItems.push({ type: "server", serverIndex: si });
293
+ if (server.expanded || query) {
294
+ for (let ti = 0; ti < server.tools.length; ti++) {
295
+ const tool = server.tools[ti];
296
+ if (!tool) continue;
297
+ if (query) {
298
+ const score = mode === "name"
299
+ ? Math.max(
300
+ fuzzyScore(query, tool.name),
301
+ fuzzyScore(query, server.name) * 0.6,
302
+ )
303
+ : fuzzyScore(query, tool.description);
304
+ if (score === 0) continue;
305
+ }
306
+ this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti });
307
+ }
308
+ }
309
+ }
310
+
311
+ if (query && !this.authOnly) {
312
+ this.visibleItems = this.visibleItems.filter((item) => {
313
+ if (item.type === "server") {
314
+ return this.visibleItems.some(
315
+ (other) => other.type === "tool" && other.serverIndex === item.serverIndex,
316
+ );
317
+ }
318
+ return true;
319
+ });
320
+ }
321
+ }
322
+
323
+ private updateDirty(): void {
324
+ this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect));
325
+ }
326
+
327
+ private buildResult(): McpPanelResult {
328
+ const changes = new Map<string, true | string[] | false>();
329
+ for (const server of this.servers) {
330
+ const changed = server.tools.some((t) => t.isDirect !== t.wasDirect);
331
+ if (!changed) continue;
332
+ const directTools = server.tools.filter((t) => t.isDirect);
333
+ if (directTools.length === server.tools.length && server.tools.length > 0) {
334
+ changes.set(server.name, true);
335
+ } else if (directTools.length === 0) {
336
+ changes.set(server.name, false);
337
+ } else {
338
+ changes.set(server.name, directTools.map((t) => t.name));
339
+ }
340
+ }
341
+ return { changes, cancelled: false };
342
+ }
343
+
344
+ handleInput(data: string): void {
345
+ this.resetInactivityTimeout();
346
+ this.importNotice = null;
347
+ if (!this.authInFlight) this.authNotice = null;
348
+
349
+ if (this.confirmingDiscard) {
350
+ this.handleDiscardInput(data);
351
+ return;
352
+ }
353
+
354
+ // Global shortcuts — always work, even during desc search
355
+ if (matchesKey(data, "ctrl+c")) {
356
+ this.cleanup();
357
+ this.done({ cancelled: true, changes: new Map() });
358
+ return;
359
+ }
360
+
361
+ if (matchesKey(data, "ctrl+s")) {
362
+ this.cleanup();
363
+ this.done(this.buildResult());
364
+ return;
365
+ }
366
+
367
+ // Modal description search mode
368
+ if (this.descSearchActive) {
369
+ if (matchesKey(data, "escape") || this.keys.selectConfirm(data)) {
370
+ this.descSearchActive = false;
371
+ this.descQuery = "";
372
+ this.rebuildVisibleItems();
373
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
374
+ return;
375
+ }
376
+ if (matchesKey(data, "backspace")) {
377
+ if (this.descQuery.length > 0) {
378
+ this.descQuery = this.descQuery.slice(0, -1);
379
+ this.rebuildVisibleItems();
380
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
381
+ }
382
+ return;
383
+ }
384
+ if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
385
+ if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
386
+ if (matchesKey(data, "space")) {
387
+ // Toggle even while in desc search
388
+ const item = this.visibleItems[this.cursorIndex];
389
+ if (item) this.toggleItem(item);
390
+ return;
391
+ }
392
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
393
+ this.descQuery += data;
394
+ this.rebuildVisibleItems();
395
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
396
+ return;
397
+ }
398
+ return;
399
+ }
400
+
401
+ if (matchesKey(data, "escape")) {
402
+ if (this.nameQuery) {
403
+ this.nameQuery = "";
404
+ this.rebuildVisibleItems();
405
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
406
+ return;
407
+ }
408
+ if (this.dirty) {
409
+ this.confirmingDiscard = true;
410
+ this.discardSelected = 1;
411
+ return;
412
+ }
413
+ this.cleanup();
414
+ this.done({ cancelled: true, changes: new Map() });
415
+ return;
416
+ }
417
+
418
+ if (this.keys.selectUp(data)) { this.moveCursor(-1); return; }
419
+ if (this.keys.selectDown(data)) { this.moveCursor(1); return; }
420
+
421
+ if (matchesKey(data, "space")) {
422
+ const item = this.visibleItems[this.cursorIndex];
423
+ if (item && !this.authOnly) this.toggleItem(item);
424
+ return;
425
+ }
426
+
427
+ if (this.keys.selectConfirm(data)) {
428
+ const item = this.visibleItems[this.cursorIndex];
429
+ if (!item) return;
430
+ const server = this.servers[item.serverIndex];
431
+ if (!server) return;
432
+ if (item.type === "server") {
433
+ if (server.connectionStatus === "disabled") return;
434
+ if (this.authOnly || server.connectionStatus === "needs-auth") {
435
+ this.authenticateServer(server);
436
+ return;
437
+ }
438
+ server.expanded = !server.expanded;
439
+ this.rebuildVisibleItems();
440
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
441
+ } else if (item.toolIndex !== undefined) {
442
+ const tool = server.tools[item.toolIndex];
443
+ if (!tool) return;
444
+ tool.isDirect = !tool.isDirect;
445
+ if (tool.isDirect && server.source === "import") {
446
+ this.importNotice = "Only tool selections save to Pi config.";
447
+ }
448
+ this.updateDirty();
449
+ }
450
+ return;
451
+ }
452
+
453
+ if (matchesKey(data, "ctrl+a")) {
454
+ const item = this.visibleItems[this.cursorIndex];
455
+ if (item) this.authenticateSelectedServer(item);
456
+ return;
457
+ }
458
+
459
+ if (matchesKey(data, "ctrl+r")) {
460
+ const item = this.visibleItems[this.cursorIndex];
461
+ if (!item) return;
462
+ const server = this.servers[item.serverIndex];
463
+ if (server) this.reconnectServer(server);
464
+ return;
465
+ }
466
+
467
+ if (matchesKey(data, "ctrl+y")) {
468
+ const item = this.visibleItems[this.cursorIndex];
469
+ if (!item) return;
470
+ const server = this.servers[item.serverIndex];
471
+ if (!server || server.connectionStatus !== "failed" || !server.failureMessage) return;
472
+ const serverName = sanitizeDisplayText(server.name);
473
+ const failureMessage = sanitizeDisplayText(server.failureMessage);
474
+ copyToClipboard(failureMessage).then(() => {
475
+ this.authNotice = `Copied error for ${serverName} to clipboard`;
476
+ this.tui.requestRender();
477
+ }).catch((error) => {
478
+ const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
479
+ this.authNotice = `Failed to copy error for ${serverName}: ${message}`;
480
+ this.tui.requestRender();
481
+ });
482
+ return;
483
+ }
484
+
485
+ if (data === "?") {
486
+ if (this.authOnly) return;
487
+ this.descSearchActive = true;
488
+ this.descQuery = "";
489
+ this.rebuildVisibleItems();
490
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
491
+ return;
492
+ }
493
+
494
+ // Backspace removes from name query
495
+ if (matchesKey(data, "backspace")) {
496
+ if (this.nameQuery.length > 0) {
497
+ this.nameQuery = this.nameQuery.slice(0, -1);
498
+ this.rebuildVisibleItems();
499
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
500
+ }
501
+ return;
502
+ }
503
+
504
+ // All other printable chars → always-on name search
505
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
506
+ this.nameQuery += data;
507
+ this.rebuildVisibleItems();
508
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
509
+ return;
510
+ }
511
+ }
512
+
513
+ private authenticateSelectedServer(item: VisibleItem): void {
514
+ const server = this.servers[item.serverIndex];
515
+ if (server) this.authenticateServer(server);
516
+ }
517
+
518
+ private authenticateServer(server: ServerState): void {
519
+ if (this.authInFlight) return;
520
+ if (server.connectionStatus === "connecting" || server.connectionStatus === "disabled") return;
521
+ const serverName = sanitizeDisplayText(server.name);
522
+ if (!this.callbacks.canAuthenticate(server.name)) {
523
+ this.authNotice = `${serverName} does not use OAuth authentication.`;
524
+ return;
525
+ }
526
+
527
+ this.authInFlight = server.name;
528
+ this.authNotice = `Authenticating ${serverName}...`;
529
+ this.tui.requestRender();
530
+
531
+ this.callbacks.authenticate(server.name).then((result) => {
532
+ server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
533
+ if (result.ok) {
534
+ this.authNotice = `OAuth finished for ${serverName}. Reconnecting...`;
535
+ this.authInFlight = null;
536
+ this.tui.requestRender();
537
+ this.reconnectServer(server, { afterAuth: true });
538
+ return;
539
+ }
540
+
541
+ const message = sanitizeDisplayText(result.message);
542
+ this.authNotice = `OAuth failed for ${serverName}${message ? `: ${message}` : ". Check the notification for details."}`;
543
+ this.authInFlight = null;
544
+ this.tui.requestRender();
545
+ }).catch((error) => {
546
+ const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
547
+ server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
548
+ this.authNotice = `OAuth failed for ${serverName}: ${message}`;
549
+ this.authInFlight = null;
550
+ this.tui.requestRender();
551
+ });
552
+ }
553
+
554
+ private reconnectServer(server: ServerState, options: { afterAuth?: boolean } = {}): void {
555
+ if (server.connectionStatus === "connecting" || server.connectionStatus === "disabled") return;
556
+ const serverName = sanitizeDisplayText(server.name);
557
+ server.connectionStatus = "connecting";
558
+ this.tui.requestRender();
559
+
560
+ this.callbacks.reconnect(server.name, options.afterAuth).then((connected) => {
561
+ server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
562
+ server.failureMessage = this.callbacks.getFailureMessage?.(server.name) ?? null;
563
+ if (server.connectionStatus === "connected") {
564
+ const entry = this.callbacks.refreshCacheAfterReconnect(server.name);
565
+ if (entry) {
566
+ this.rebuildServerTools(server, entry);
567
+ }
568
+ server.hasCachedData = true;
569
+ }
570
+ if (options.afterAuth) {
571
+ this.authNotice = connected && server.connectionStatus === "connected"
572
+ ? `OAuth finished for ${serverName}. Reconnected.`
573
+ : `OAuth finished for ${serverName}, but reconnect did not complete. Press ctrl+r to retry.`;
574
+ }
575
+ this.tui.requestRender();
576
+ }).catch((error) => {
577
+ server.connectionStatus = "failed";
578
+ const message = sanitizeDisplayText(error instanceof Error ? error.message : String(error));
579
+ this.authNotice = `Reconnect failed for ${serverName}: ${message}`;
580
+ this.tui.requestRender();
581
+ });
582
+ }
583
+
584
+ private toggleItem(item: VisibleItem): void {
585
+ if (this.authOnly) return;
586
+ const server = this.servers[item.serverIndex];
587
+ if (!server) return;
588
+ if (item.type === "server") {
589
+ const newState = !server.tools.every((t) => t.isDirect);
590
+ if (server.source === "import" && newState) {
591
+ this.importNotice = "Only tool selections save to Pi config.";
592
+ }
593
+ for (const t of server.tools) t.isDirect = newState;
594
+ } else if (item.toolIndex !== undefined) {
595
+ const tool = server.tools[item.toolIndex];
596
+ if (!tool) return;
597
+ tool.isDirect = !tool.isDirect;
598
+ if (tool.isDirect && server.source === "import") {
599
+ this.importNotice = "Only tool selections save to Pi config.";
600
+ }
601
+ }
602
+ this.updateDirty();
603
+ }
604
+
605
+ private handleDiscardInput(data: string): void {
606
+ if (matchesKey(data, "ctrl+c")) {
607
+ this.cleanup();
608
+ this.done({ cancelled: true, changes: new Map() });
609
+ return;
610
+ }
611
+ if (matchesKey(data, "escape") || data === "n" || data === "N") {
612
+ this.confirmingDiscard = false;
613
+ return;
614
+ }
615
+ if (this.keys.selectConfirm(data)) {
616
+ this.cleanup();
617
+ if (this.discardSelected === 0) {
618
+ this.done({ cancelled: true, changes: new Map() });
619
+ } else {
620
+ this.done(this.buildResult());
621
+ }
622
+ return;
623
+ }
624
+ if (data === "y" || data === "Y") {
625
+ this.cleanup();
626
+ this.done({ cancelled: true, changes: new Map() });
627
+ return;
628
+ }
629
+ if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) {
630
+ this.discardSelected = this.discardSelected === 0 ? 1 : 0;
631
+ }
632
+ }
633
+
634
+ private moveCursor(delta: number): void {
635
+ if (this.visibleItems.length === 0) return;
636
+ this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta));
637
+ }
638
+
639
+ private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void {
640
+ const existingState = new Map<string, boolean>();
641
+ for (const t of server.tools) existingState.set(t.name, t.isDirect);
642
+
643
+ const newTools: ToolState[] = [];
644
+ for (const tool of entry.tools ?? []) {
645
+ if (!isToolAllowed(tool.name, server.name, this.prefix, server.includeTools, server.excludeTools)) {
646
+ continue;
647
+ }
648
+
649
+ const prev = existingState.get(tool.name);
650
+ const isDirect = prev !== undefined ? prev : false;
651
+ newTools.push({
652
+ name: tool.name,
653
+ description: tool.description ?? "",
654
+ isDirect,
655
+ wasDirect: prev !== undefined ? server.tools.find((t) => t.name === tool.name)?.wasDirect ?? false : false,
656
+ estimatedTokens: estimateTokens(tool),
657
+ });
658
+ }
659
+
660
+ if (server.exposeResources) {
661
+ for (const resource of entry.resources ?? []) {
662
+ const baseName = `read_${resourceNameToToolName(resource.name)}`;
663
+ if (!isToolAllowed(baseName, server.name, this.prefix, server.includeTools, server.excludeTools)) {
664
+ continue;
665
+ }
666
+
667
+ const prev = existingState.get(baseName);
668
+ const isDirect = prev !== undefined ? prev : false;
669
+ const ct: CachedTool = {
670
+ name: baseName,
671
+ ...(resource.description !== undefined ? { description: resource.description } : {}),
672
+ };
673
+ newTools.push({
674
+ name: baseName,
675
+ description: resource.description ?? `Read resource: ${resource.uri}`,
676
+ isDirect,
677
+ wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
678
+ estimatedTokens: estimateTokens(ct),
679
+ });
680
+ }
681
+ }
682
+
683
+ server.tools = newTools;
684
+ this.rebuildVisibleItems();
685
+ this.updateDirty();
686
+ }
687
+
688
+ render(width: number): string[] {
689
+ const innerW = width - 2;
690
+ const lines: string[] = [];
691
+ const t = this.t;
692
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
693
+ const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
694
+ const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
695
+
696
+ const row = (content: string) =>
697
+ fg(t.border, "│") + truncateToWidth(" " + sanitizeRowContent(content), innerW, "…", true) + fg(t.border, "│");
698
+ const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
699
+ const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
700
+
701
+ const titleText = this.authOnly ? " MCP OAuth " : " MCP Servers ";
702
+ const borderLen = innerW - visibleWidth(titleText);
703
+ const leftB = Math.floor(borderLen / 2);
704
+ const rightB = borderLen - leftB;
705
+ lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
706
+
707
+ lines.push(emptyRow());
708
+
709
+ const cursor = fg(t.selected, "│");
710
+ const searchIcon = fg(t.border, "◎");
711
+ if (this.descSearchActive) {
712
+ lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`));
713
+ } else if (this.nameQuery) {
714
+ lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`));
715
+ } else {
716
+ lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
717
+ }
718
+
719
+ lines.push(emptyRow());
720
+ if (this.noticeLines.length > 0) {
721
+ for (const notice of this.noticeLines) {
722
+ lines.push(row(fg(t.hint, italic(sanitizeDisplayText(notice)))));
723
+ }
724
+ lines.push(emptyRow());
725
+ }
726
+ lines.push(divider());
727
+
728
+ if (this.servers.length === 0) {
729
+ lines.push(emptyRow());
730
+ lines.push(row(fg(t.hint, italic(this.authOnly ? "No OAuth-capable MCP servers configured." : "No MCP servers configured."))));
731
+ lines.push(emptyRow());
732
+ } else {
733
+ const maxVis = McpPanel.MAX_VISIBLE;
734
+ const total = this.visibleItems.length;
735
+ const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
736
+ const endIdx = Math.min(startIdx + maxVis, total);
737
+
738
+ lines.push(emptyRow());
739
+
740
+ for (let i = startIdx; i < endIdx; i++) {
741
+ const item = this.visibleItems[i];
742
+ if (!item) continue;
743
+ const isCursor = i === this.cursorIndex;
744
+ const server = this.servers[item.serverIndex];
745
+ if (!server) continue;
746
+
747
+ if (item.type === "server") {
748
+ lines.push(row(this.renderServerRow(server, isCursor)));
749
+ if (isCursor && server.connectionStatus === "failed" && server.failureMessage) {
750
+ for (const line of this.wrapText(sanitizeDisplayText(server.failureMessage), innerW - 6)) {
751
+ lines.push(row(` ${fg(t.cancel, line)}`));
752
+ }
753
+ }
754
+ } else if (item.toolIndex !== undefined) {
755
+ const tool = server.tools[item.toolIndex];
756
+ if (tool) lines.push(row(this.renderToolRow(tool, isCursor, innerW)));
757
+ }
758
+ }
759
+
760
+ lines.push(emptyRow());
761
+
762
+ if (total > maxVis) {
763
+ const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
764
+ lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
765
+ lines.push(emptyRow());
766
+ }
767
+
768
+ if (this.importNotice) {
769
+ lines.push(row(fg(t.needsAuth, italic(sanitizeDisplayText(this.importNotice)))));
770
+ lines.push(emptyRow());
771
+ }
772
+ if (this.authNotice) {
773
+ lines.push(row(fg(t.needsAuth, italic(sanitizeDisplayText(this.authNotice)))));
774
+ lines.push(emptyRow());
775
+ }
776
+ }
777
+
778
+ lines.push(divider());
779
+ lines.push(emptyRow());
780
+
781
+ if (this.confirmingDiscard) {
782
+ const discardBtn = this.discardSelected === 0
783
+ ? inverse(bold(fg(t.cancel, " Discard ")))
784
+ : fg(t.hint, " Discard ");
785
+ const keepBtn = this.discardSelected === 1
786
+ ? inverse(bold(fg(t.confirm, " Keep & Close ")))
787
+ : fg(t.hint, " Keep & Close ");
788
+ lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`));
789
+ } else {
790
+ if (this.authOnly) {
791
+ lines.push(row(fg(t.description, "select a server to authenticate")));
792
+ } else {
793
+ const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0);
794
+ const totalTokens = this.servers.reduce(
795
+ (sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0),
796
+ 0,
797
+ );
798
+ const stats =
799
+ directCount > 0 ? `${directCount} direct ~${totalTokens.toLocaleString()} tokens` : "no direct tools";
800
+ lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : ""))));
801
+ }
802
+ }
803
+
804
+ lines.push(emptyRow());
805
+ const hints = this.authOnly
806
+ ? [
807
+ italic("↑↓") + " navigate",
808
+ italic("⏎") + " auth",
809
+ italic("ctrl+a") + " auth",
810
+ italic("esc") + " clear/close",
811
+ italic("ctrl+c") + " quit",
812
+ ]
813
+ : [
814
+ italic("↑↓") + " navigate",
815
+ italic("space") + " toggle",
816
+ italic("⏎") + " expand/auth",
817
+ italic("ctrl+a") + " auth",
818
+ italic("ctrl+r") + " reconnect",
819
+ ...(this.selectedServerHasFailureMessage() ? [italic("ctrl+y") + " copy error"] : []),
820
+ italic("?") + " desc search",
821
+ italic("ctrl+s") + " save",
822
+ italic("esc") + " clear/close",
823
+ italic("ctrl+c") + " quit",
824
+ ];
825
+ const gap = " ";
826
+ const gapW = 2;
827
+ const maxW = innerW - 2;
828
+ let curLine = "";
829
+ let curW = 0;
830
+ for (const hint of hints) {
831
+ const hw = visibleWidth(hint);
832
+ const needed = curW === 0 ? hw : gapW + hw;
833
+ if (curW > 0 && curW + needed > maxW) {
834
+ lines.push(row(fg(t.hint, curLine)));
835
+ curLine = hint;
836
+ curW = hw;
837
+ } else {
838
+ curLine += (curW > 0 ? gap : "") + hint;
839
+ curW += needed;
840
+ }
841
+ }
842
+ if (curLine) lines.push(row(fg(t.hint, curLine)));
843
+
844
+ lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
845
+
846
+ return lines;
847
+ }
848
+
849
+ private renderServerRow(server: ServerState, isCursor: boolean): string {
850
+ const t = this.t;
851
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
852
+
853
+ const expandIcon = server.expanded ? "▾" : "▸";
854
+ const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·");
855
+
856
+ const serverName = sanitizeDisplayText(server.name);
857
+ const importKind = sanitizeDisplayText(server.importKind ?? "import");
858
+ const nameStr = isCursor ? bold(fg(t.selected, serverName)) : serverName;
859
+ const importLabel = server.source === "import" ? fg(t.description, ` (${importKind})`) : "";
860
+ const statusLabel = this.renderConnectionStatus(server);
861
+
862
+ if (!server.hasCachedData && !this.authOnly) {
863
+ return `${prefix} ${nameStr}${importLabel} ${fg(t.description, "(not cached)")}${statusLabel}`;
864
+ }
865
+
866
+ const directCount = server.tools.filter((t) => t.isDirect).length;
867
+ const totalCount = server.tools.length;
868
+ let toggleIcon = fg(t.description, "○");
869
+ if (directCount === totalCount && totalCount > 0) {
870
+ toggleIcon = fg(t.direct, "●");
871
+ } else if (directCount > 0) {
872
+ toggleIcon = fg(t.needsAuth, "◐");
873
+ }
874
+
875
+ let toolInfo = "";
876
+ if (totalCount > 0) {
877
+ toolInfo = `${directCount}/${totalCount}`;
878
+ if (directCount > 0) {
879
+ const tokens = server.tools.filter((t) => t.isDirect).reduce((s, t) => s + t.estimatedTokens, 0);
880
+ toolInfo += ` ~${tokens.toLocaleString()}`;
881
+ }
882
+ toolInfo = fg(t.description, toolInfo);
883
+ }
884
+
885
+ return `${prefix} ${toggleIcon} ${nameStr}${importLabel} ${toolInfo}${statusLabel}`;
886
+ }
887
+
888
+ private selectedServerHasFailureMessage(): boolean {
889
+ const item = this.visibleItems[this.cursorIndex];
890
+ if (!item) return false;
891
+ const server = this.servers[item.serverIndex];
892
+ return server?.connectionStatus === "failed" && !!server.failureMessage;
893
+ }
894
+
895
+ private wrapText(text: string, width: number): string[] {
896
+ const max = Math.max(8, width);
897
+ const words = text.split(/\s+/).filter(Boolean);
898
+ const lines: string[] = [];
899
+ let current = "";
900
+ const splitLongWord = (word: string): string => {
901
+ let rest = word;
902
+ while (visibleWidth(rest) > max) {
903
+ let take = "";
904
+ let index = 0;
905
+ while (index < rest.length && visibleWidth(take + rest.charAt(index)) <= max) {
906
+ take += rest.charAt(index);
907
+ index++;
908
+ }
909
+ if (!take) take = rest.charAt(0);
910
+ lines.push(take);
911
+ rest = rest.slice(take.length);
912
+ }
913
+ return rest;
914
+ };
915
+
916
+ for (const word of words) {
917
+ const candidate = current ? `${current} ${word}` : word;
918
+ if (visibleWidth(candidate) <= max) {
919
+ current = candidate;
920
+ } else {
921
+ if (current) lines.push(current);
922
+ current = splitLongWord(word);
923
+ }
924
+ }
925
+ if (current) lines.push(current);
926
+ return lines.length > 0 ? lines : [text];
927
+ }
928
+
929
+ private renderConnectionStatus(server: ServerState): string {
930
+ const t = this.t;
931
+ if (this.authInFlight === server.name) return ` ${fg(t.needsAuth, "authenticating")}`;
932
+ if (server.connectionStatus === "disabled") return ` ${fg(t.description, "disabled")}`;
933
+ if (server.connectionStatus === "needs-auth") return ` ${fg(t.needsAuth, "needs auth")}`;
934
+ if (server.connectionStatus === "connecting") return ` ${fg(t.needsAuth, "connecting")}`;
935
+ if (server.connectionStatus === "failed") return ` ${fg(t.cancel, "failed")}`;
936
+ if (this.authOnly && server.connectionStatus === "connected") return ` ${fg(t.direct, "connected")}`;
937
+ if (this.authOnly) return ` ${fg(t.description, "idle")}`;
938
+ return "";
939
+ }
940
+
941
+ private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string {
942
+ const t = this.t;
943
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
944
+
945
+ const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○");
946
+ const cursor = isCursor ? fg(t.selected, "▸") : " ";
947
+ const toolName = sanitizeDisplayText(tool.name);
948
+ const description = sanitizeDisplayText(tool.description);
949
+ const nameStr = isCursor ? bold(fg(t.selected, toolName)) : toolName;
950
+
951
+ const prefixLen = 7 + visibleWidth(toolName);
952
+ const maxDescLen = Math.max(0, innerW - prefixLen - 8);
953
+ const descStr =
954
+ maxDescLen > 5 && description
955
+ ? fg(t.description, "— " + truncateToWidth(description, maxDescLen, "…"))
956
+ : "";
957
+
958
+ return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}`;
959
+ }
960
+
961
+ invalidate(): void {}
962
+
963
+ dispose(): void {
964
+ this.cleanup();
965
+ }
966
+ }
967
+
968
+ export function createMcpPanel(
969
+ config: McpConfig,
970
+ cache: MetadataCache | null,
971
+ provenance: Map<string, ServerProvenance>,
972
+ callbacks: McpPanelCallbacks,
973
+ tui: { requestRender(): void },
974
+ done: (result: McpPanelResult) => void,
975
+ options?: { noticeLines?: string[]; authOnly?: boolean; keybindings?: PanelKeybindings },
976
+ ): McpPanel & { dispose(): void } {
977
+ return new McpPanel(config, cache, provenance, callbacks, tui, done, options ?? {});
978
+ }