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