pi-mcp-adapter 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-panel.ts ADDED
@@ -0,0 +1,713 @@
1
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.js";
3
+ import { resourceNameToToolName } from "./resource-tools.js";
4
+ import type { MetadataCache, ServerCacheEntry, CachedTool } from "./metadata-cache.js";
5
+
6
+ interface PanelTheme {
7
+ border: string;
8
+ title: string;
9
+ selected: string;
10
+ direct: string;
11
+ needsAuth: string;
12
+ placeholder: string;
13
+ description: string;
14
+ hint: string;
15
+ confirm: string;
16
+ cancel: string;
17
+ }
18
+
19
+ const DEFAULT_THEME: PanelTheme = {
20
+ border: "2",
21
+ title: "2",
22
+ selected: "36",
23
+ direct: "32",
24
+ needsAuth: "33",
25
+ placeholder: "2;3",
26
+ description: "2",
27
+ hint: "2",
28
+ confirm: "32",
29
+ cancel: "31",
30
+ };
31
+
32
+ function fg(code: string, text: string): string {
33
+ if (!code) return text;
34
+ return `\x1b[${code}m${text}\x1b[0m`;
35
+ }
36
+
37
+ const RAINBOW_COLORS = [
38
+ "38;2;178;129;214",
39
+ "38;2;215;135;175",
40
+ "38;2;254;188;56",
41
+ "38;2;228;192;15",
42
+ "38;2;137;210;129",
43
+ "38;2;0;175;175",
44
+ "38;2;23;143;185",
45
+ ];
46
+
47
+ function rainbowProgress(filled: number, total: number): string {
48
+ const dots: string[] = [];
49
+ for (let i = 0; i < total; i++) {
50
+ const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length];
51
+ dots.push(fg(color, i < filled ? "●" : "○"));
52
+ }
53
+ return dots.join(" ");
54
+ }
55
+
56
+ function fuzzyScore(query: string, text: string): number {
57
+ const lq = query.toLowerCase();
58
+ const lt = text.toLowerCase();
59
+ if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50;
60
+ let score = 0;
61
+ let qi = 0;
62
+ let consecutive = 0;
63
+ for (let i = 0; i < lt.length && qi < lq.length; i++) {
64
+ if (lt[i] === lq[qi]) {
65
+ score += 10 + consecutive;
66
+ consecutive += 5;
67
+ qi++;
68
+ } else {
69
+ consecutive = 0;
70
+ }
71
+ }
72
+ return qi === lq.length ? score : 0;
73
+ }
74
+
75
+ function estimateTokens(tool: CachedTool): number {
76
+ const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length;
77
+ const descLen = tool.description?.length ?? 0;
78
+ return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10;
79
+ }
80
+
81
+ type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting";
82
+
83
+ interface ToolState {
84
+ name: string;
85
+ description: string;
86
+ isDirect: boolean;
87
+ wasDirect: boolean;
88
+ estimatedTokens: number;
89
+ }
90
+
91
+ interface ServerState {
92
+ name: string;
93
+ expanded: boolean;
94
+ source: "user" | "project" | "import";
95
+ importKind?: string;
96
+ connectionStatus: ConnectionStatus;
97
+ tools: ToolState[];
98
+ hasCachedData: boolean;
99
+ }
100
+
101
+ interface VisibleItem {
102
+ type: "server" | "tool";
103
+ serverIndex: number;
104
+ toolIndex?: number;
105
+ }
106
+
107
+ class McpPanel {
108
+ private servers: ServerState[] = [];
109
+ private cursorIndex = 0;
110
+ private nameQuery = "";
111
+ private descSearchActive = false;
112
+ private descQuery = "";
113
+ private dirty = false;
114
+ private confirmingDiscard = false;
115
+ private discardSelected = 1;
116
+ private importNotice: string | null = null;
117
+ private authNotice: string | null = null;
118
+ private inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
119
+ private visibleItems: VisibleItem[] = [];
120
+ private tui: { requestRender(): void };
121
+ private t = DEFAULT_THEME;
122
+
123
+ private static readonly MAX_VISIBLE = 12;
124
+ private static readonly INACTIVITY_MS = 60_000;
125
+
126
+ constructor(
127
+ config: McpConfig,
128
+ cache: MetadataCache | null,
129
+ provenance: Map<string, ServerProvenance>,
130
+ private callbacks: McpPanelCallbacks,
131
+ tui: { requestRender(): void },
132
+ private done: (result: McpPanelResult) => void,
133
+ ) {
134
+ this.tui = tui;
135
+
136
+ for (const [serverName, definition] of Object.entries(config.mcpServers)) {
137
+ const prov = provenance.get(serverName);
138
+ const serverCache = cache?.servers?.[serverName];
139
+
140
+ const globalDirect = config.settings?.directTools;
141
+ let toolFilter: true | string[] | false = false;
142
+ if (definition.directTools !== undefined) {
143
+ toolFilter = definition.directTools;
144
+ } else if (globalDirect) {
145
+ toolFilter = globalDirect;
146
+ }
147
+
148
+ const tools: ToolState[] = [];
149
+ if (serverCache) {
150
+ for (const tool of serverCache.tools ?? []) {
151
+ const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name));
152
+ tools.push({
153
+ name: tool.name,
154
+ description: tool.description ?? "",
155
+ isDirect,
156
+ wasDirect: isDirect,
157
+ estimatedTokens: estimateTokens(tool),
158
+ });
159
+ }
160
+ if (definition.exposeResources !== false) {
161
+ for (const resource of serverCache.resources ?? []) {
162
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
163
+ const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName));
164
+ const ct: CachedTool = { name: baseName, description: resource.description };
165
+ tools.push({
166
+ name: baseName,
167
+ description: resource.description ?? `Read resource: ${resource.uri}`,
168
+ isDirect,
169
+ wasDirect: isDirect,
170
+ estimatedTokens: estimateTokens(ct),
171
+ });
172
+ }
173
+ }
174
+ }
175
+
176
+ const status = callbacks.getConnectionStatus(serverName);
177
+
178
+ this.servers.push({
179
+ name: serverName,
180
+ expanded: false,
181
+ source: prov?.kind ?? "user",
182
+ importKind: prov?.importKind,
183
+ connectionStatus: status,
184
+ tools,
185
+ hasCachedData: !!serverCache,
186
+ });
187
+ }
188
+
189
+ this.rebuildVisibleItems();
190
+ this.resetInactivityTimeout();
191
+ }
192
+
193
+ private resetInactivityTimeout(): void {
194
+ if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout);
195
+ this.inactivityTimeout = setTimeout(() => {
196
+ this.cleanup();
197
+ this.done({ cancelled: true, changes: new Map() });
198
+ }, McpPanel.INACTIVITY_MS);
199
+ }
200
+
201
+ private cleanup(): void {
202
+ if (this.inactivityTimeout) {
203
+ clearTimeout(this.inactivityTimeout);
204
+ this.inactivityTimeout = null;
205
+ }
206
+ }
207
+
208
+ private rebuildVisibleItems(): void {
209
+ const query = this.descSearchActive ? this.descQuery : this.nameQuery;
210
+ const mode = this.descSearchActive ? "desc" : "name";
211
+
212
+ this.visibleItems = [];
213
+ for (let si = 0; si < this.servers.length; si++) {
214
+ const server = this.servers[si];
215
+ this.visibleItems.push({ type: "server", serverIndex: si });
216
+ if (server.expanded || query) {
217
+ for (let ti = 0; ti < server.tools.length; ti++) {
218
+ const tool = server.tools[ti];
219
+ if (query) {
220
+ const score = mode === "name"
221
+ ? Math.max(
222
+ fuzzyScore(query, tool.name),
223
+ fuzzyScore(query, server.name) * 0.6,
224
+ )
225
+ : fuzzyScore(query, tool.description);
226
+ if (score === 0) continue;
227
+ }
228
+ this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti });
229
+ }
230
+ }
231
+ }
232
+
233
+ if (query) {
234
+ this.visibleItems = this.visibleItems.filter((item) => {
235
+ if (item.type === "server") {
236
+ return this.visibleItems.some(
237
+ (other) => other.type === "tool" && other.serverIndex === item.serverIndex,
238
+ );
239
+ }
240
+ return true;
241
+ });
242
+ }
243
+ }
244
+
245
+ private updateDirty(): void {
246
+ this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect));
247
+ }
248
+
249
+ private buildResult(): McpPanelResult {
250
+ const changes = new Map<string, true | string[] | false>();
251
+ for (const server of this.servers) {
252
+ const changed = server.tools.some((t) => t.isDirect !== t.wasDirect);
253
+ if (!changed) continue;
254
+ const directTools = server.tools.filter((t) => t.isDirect);
255
+ if (directTools.length === server.tools.length && server.tools.length > 0) {
256
+ changes.set(server.name, true);
257
+ } else if (directTools.length === 0) {
258
+ changes.set(server.name, false);
259
+ } else {
260
+ changes.set(server.name, directTools.map((t) => t.name));
261
+ }
262
+ }
263
+ return { changes, cancelled: false };
264
+ }
265
+
266
+ handleInput(data: string): void {
267
+ this.resetInactivityTimeout();
268
+ this.importNotice = null;
269
+ this.authNotice = null;
270
+
271
+ if (this.confirmingDiscard) {
272
+ this.handleDiscardInput(data);
273
+ return;
274
+ }
275
+
276
+ // Global shortcuts — always work, even during desc search
277
+ if (matchesKey(data, "ctrl+c")) {
278
+ this.cleanup();
279
+ this.done({ cancelled: true, changes: new Map() });
280
+ return;
281
+ }
282
+
283
+ if (matchesKey(data, "ctrl+s")) {
284
+ this.cleanup();
285
+ this.done(this.buildResult());
286
+ return;
287
+ }
288
+
289
+ // Modal description search mode
290
+ if (this.descSearchActive) {
291
+ if (matchesKey(data, "escape") || matchesKey(data, "return")) {
292
+ this.descSearchActive = false;
293
+ this.descQuery = "";
294
+ this.rebuildVisibleItems();
295
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
296
+ return;
297
+ }
298
+ if (matchesKey(data, "backspace")) {
299
+ if (this.descQuery.length > 0) {
300
+ this.descQuery = this.descQuery.slice(0, -1);
301
+ this.rebuildVisibleItems();
302
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
303
+ }
304
+ return;
305
+ }
306
+ if (matchesKey(data, "up")) { this.moveCursor(-1); return; }
307
+ if (matchesKey(data, "down")) { this.moveCursor(1); return; }
308
+ if (matchesKey(data, "space")) {
309
+ // Toggle even while in desc search
310
+ const item = this.visibleItems[this.cursorIndex];
311
+ if (item) this.toggleItem(item);
312
+ return;
313
+ }
314
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
315
+ this.descQuery += data;
316
+ this.rebuildVisibleItems();
317
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
318
+ return;
319
+ }
320
+ return;
321
+ }
322
+
323
+ if (matchesKey(data, "escape")) {
324
+ if (this.nameQuery) {
325
+ this.nameQuery = "";
326
+ this.rebuildVisibleItems();
327
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
328
+ return;
329
+ }
330
+ if (this.dirty) {
331
+ this.confirmingDiscard = true;
332
+ this.discardSelected = 1;
333
+ return;
334
+ }
335
+ this.cleanup();
336
+ this.done({ cancelled: true, changes: new Map() });
337
+ return;
338
+ }
339
+
340
+ if (matchesKey(data, "up")) { this.moveCursor(-1); return; }
341
+ if (matchesKey(data, "down")) { this.moveCursor(1); return; }
342
+
343
+ if (matchesKey(data, "space")) {
344
+ const item = this.visibleItems[this.cursorIndex];
345
+ if (item) this.toggleItem(item);
346
+ return;
347
+ }
348
+
349
+ if (matchesKey(data, "return")) {
350
+ const item = this.visibleItems[this.cursorIndex];
351
+ if (!item) return;
352
+ const server = this.servers[item.serverIndex];
353
+ if (item.type === "server") {
354
+ if (server.connectionStatus === "needs-auth") {
355
+ this.authNotice = `OAuth required — run /mcp-auth ${server.name} after closing this panel`;
356
+ return;
357
+ }
358
+ server.expanded = !server.expanded;
359
+ this.rebuildVisibleItems();
360
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
361
+ } else if (item.toolIndex !== undefined) {
362
+ const tool = server.tools[item.toolIndex];
363
+ tool.isDirect = !tool.isDirect;
364
+ if (tool.isDirect && server.source === "import") {
365
+ this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
366
+ }
367
+ this.updateDirty();
368
+ }
369
+ return;
370
+ }
371
+
372
+ if (matchesKey(data, "ctrl+r")) {
373
+ const item = this.visibleItems[this.cursorIndex];
374
+ if (!item) return;
375
+ const server = this.servers[item.serverIndex];
376
+ if (server.connectionStatus === "connecting") return;
377
+ server.connectionStatus = "connecting";
378
+ this.callbacks.reconnect(server.name).then(() => {
379
+ server.connectionStatus = this.callbacks.getConnectionStatus(server.name);
380
+ if (server.connectionStatus === "connected") {
381
+ const entry = this.callbacks.refreshCacheAfterReconnect(server.name);
382
+ if (entry) {
383
+ this.rebuildServerTools(server, entry);
384
+ }
385
+ server.hasCachedData = true;
386
+ }
387
+ this.tui.requestRender();
388
+ }).catch(() => {
389
+ server.connectionStatus = "failed";
390
+ this.tui.requestRender();
391
+ });
392
+ return;
393
+ }
394
+
395
+ if (data === "?") {
396
+ this.descSearchActive = true;
397
+ this.descQuery = "";
398
+ this.rebuildVisibleItems();
399
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
400
+ return;
401
+ }
402
+
403
+ // Backspace removes from name query
404
+ if (matchesKey(data, "backspace")) {
405
+ if (this.nameQuery.length > 0) {
406
+ this.nameQuery = this.nameQuery.slice(0, -1);
407
+ this.rebuildVisibleItems();
408
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
409
+ }
410
+ return;
411
+ }
412
+
413
+ // All other printable chars → always-on name search
414
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
415
+ this.nameQuery += data;
416
+ this.rebuildVisibleItems();
417
+ this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1));
418
+ return;
419
+ }
420
+ }
421
+
422
+ private toggleItem(item: VisibleItem): void {
423
+ const server = this.servers[item.serverIndex];
424
+ if (item.type === "server") {
425
+ const newState = !server.tools.every((t) => t.isDirect);
426
+ if (server.source === "import" && newState) {
427
+ this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
428
+ }
429
+ for (const t of server.tools) t.isDirect = newState;
430
+ } else if (item.toolIndex !== undefined) {
431
+ const tool = server.tools[item.toolIndex];
432
+ tool.isDirect = !tool.isDirect;
433
+ if (tool.isDirect && server.source === "import") {
434
+ this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save`;
435
+ }
436
+ }
437
+ this.updateDirty();
438
+ }
439
+
440
+ private handleDiscardInput(data: string): void {
441
+ if (matchesKey(data, "ctrl+c")) {
442
+ this.cleanup();
443
+ this.done({ cancelled: true, changes: new Map() });
444
+ return;
445
+ }
446
+ if (matchesKey(data, "escape") || data === "n" || data === "N") {
447
+ this.confirmingDiscard = false;
448
+ return;
449
+ }
450
+ if (matchesKey(data, "return")) {
451
+ if (this.discardSelected === 0) {
452
+ this.cleanup();
453
+ this.done({ cancelled: true, changes: new Map() });
454
+ } else {
455
+ this.confirmingDiscard = false;
456
+ }
457
+ return;
458
+ }
459
+ if (data === "y" || data === "Y") {
460
+ this.cleanup();
461
+ this.done({ cancelled: true, changes: new Map() });
462
+ return;
463
+ }
464
+ if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) {
465
+ this.discardSelected = this.discardSelected === 0 ? 1 : 0;
466
+ }
467
+ }
468
+
469
+ private moveCursor(delta: number): void {
470
+ if (this.visibleItems.length === 0) return;
471
+ this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta));
472
+ }
473
+
474
+ private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void {
475
+ const existingState = new Map<string, boolean>();
476
+ for (const t of server.tools) existingState.set(t.name, t.isDirect);
477
+
478
+ const newTools: ToolState[] = [];
479
+ for (const tool of entry.tools ?? []) {
480
+ const prev = existingState.get(tool.name);
481
+ const isDirect = prev !== undefined ? prev : false;
482
+ newTools.push({
483
+ name: tool.name,
484
+ description: tool.description ?? "",
485
+ isDirect,
486
+ wasDirect: prev !== undefined ? server.tools.find((t) => t.name === tool.name)?.wasDirect ?? false : false,
487
+ estimatedTokens: estimateTokens(tool),
488
+ });
489
+ }
490
+
491
+ for (const resource of entry.resources ?? []) {
492
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
493
+ const prev = existingState.get(baseName);
494
+ const isDirect = prev !== undefined ? prev : false;
495
+ const ct: CachedTool = { name: baseName, description: resource.description };
496
+ newTools.push({
497
+ name: baseName,
498
+ description: resource.description ?? `Read resource: ${resource.uri}`,
499
+ isDirect,
500
+ wasDirect: prev !== undefined ? server.tools.find((t) => t.name === baseName)?.wasDirect ?? false : false,
501
+ estimatedTokens: estimateTokens(ct),
502
+ });
503
+ }
504
+
505
+ server.tools = newTools;
506
+ this.rebuildVisibleItems();
507
+ this.updateDirty();
508
+ }
509
+
510
+ render(width: number): string[] {
511
+ const innerW = width - 2;
512
+ const lines: string[] = [];
513
+ const t = this.t;
514
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
515
+ const italic = (s: string) => `\x1b[3m${s}\x1b[23m`;
516
+ const inverse = (s: string) => `\x1b[7m${s}\x1b[27m`;
517
+
518
+ const row = (content: string) =>
519
+ fg(t.border, "│") + truncateToWidth(" " + content, innerW, "…", true) + fg(t.border, "│");
520
+ const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│");
521
+ const divider = () => fg(t.border, "├" + "─".repeat(innerW) + "┤");
522
+
523
+ const titleText = " MCP Servers ";
524
+ const borderLen = innerW - visibleWidth(titleText);
525
+ const leftB = Math.floor(borderLen / 2);
526
+ const rightB = borderLen - leftB;
527
+ lines.push(fg(t.border, "╭" + "─".repeat(leftB)) + fg(t.title, titleText) + fg(t.border, "─".repeat(rightB) + "╮"));
528
+
529
+ lines.push(emptyRow());
530
+
531
+ const cursor = fg(t.selected, "│");
532
+ const searchIcon = fg(t.border, "◎");
533
+ if (this.descSearchActive) {
534
+ lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`));
535
+ } else if (this.nameQuery) {
536
+ lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`));
537
+ } else {
538
+ lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`));
539
+ }
540
+
541
+ lines.push(emptyRow());
542
+ lines.push(divider());
543
+
544
+ if (this.servers.length === 0) {
545
+ lines.push(emptyRow());
546
+ lines.push(row(fg(t.hint, italic("No MCP servers configured."))));
547
+ lines.push(emptyRow());
548
+ } else {
549
+ const maxVis = McpPanel.MAX_VISIBLE;
550
+ const total = this.visibleItems.length;
551
+ const startIdx = Math.max(0, Math.min(this.cursorIndex - Math.floor(maxVis / 2), total - maxVis));
552
+ const endIdx = Math.min(startIdx + maxVis, total);
553
+
554
+ lines.push(emptyRow());
555
+
556
+ for (let i = startIdx; i < endIdx; i++) {
557
+ const item = this.visibleItems[i];
558
+ const isCursor = i === this.cursorIndex;
559
+ const server = this.servers[item.serverIndex];
560
+
561
+ if (item.type === "server") {
562
+ lines.push(row(this.renderServerRow(server, isCursor)));
563
+ } else if (item.toolIndex !== undefined) {
564
+ lines.push(row(this.renderToolRow(server.tools[item.toolIndex], isCursor, innerW)));
565
+ }
566
+ }
567
+
568
+ lines.push(emptyRow());
569
+
570
+ if (total > maxVis) {
571
+ const prog = Math.round(((this.cursorIndex + 1) / total) * 10);
572
+ lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`));
573
+ lines.push(emptyRow());
574
+ }
575
+
576
+ if (this.importNotice) {
577
+ lines.push(row(fg(t.needsAuth, italic(this.importNotice))));
578
+ lines.push(emptyRow());
579
+ }
580
+ if (this.authNotice) {
581
+ lines.push(row(fg(t.needsAuth, italic(this.authNotice))));
582
+ lines.push(emptyRow());
583
+ }
584
+ }
585
+
586
+ lines.push(divider());
587
+ lines.push(emptyRow());
588
+
589
+ if (this.confirmingDiscard) {
590
+ const discardBtn = this.discardSelected === 0
591
+ ? inverse(bold(fg(t.cancel, " Discard ")))
592
+ : fg(t.hint, " Discard ");
593
+ const keepBtn = this.discardSelected === 1
594
+ ? inverse(bold(fg(t.confirm, " Keep ")))
595
+ : fg(t.hint, " Keep ");
596
+ lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`));
597
+ } else {
598
+ const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0);
599
+ const totalTokens = this.servers.reduce(
600
+ (sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0),
601
+ 0,
602
+ );
603
+ const stats =
604
+ directCount > 0 ? `${directCount} direct ~${totalTokens.toLocaleString()} tokens` : "no direct tools";
605
+ lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : ""))));
606
+ }
607
+
608
+ lines.push(emptyRow());
609
+ const hints = [
610
+ italic("↑↓") + " navigate",
611
+ italic("space") + " toggle",
612
+ italic("⏎") + " expand",
613
+ italic("ctrl+r") + " reconnect",
614
+ italic("?") + " desc search",
615
+ italic("ctrl+s") + " save",
616
+ italic("esc") + " clear/close",
617
+ italic("ctrl+c") + " quit",
618
+ ];
619
+ const gap = " ";
620
+ const gapW = 2;
621
+ const maxW = innerW - 2;
622
+ let curLine = "";
623
+ let curW = 0;
624
+ for (const hint of hints) {
625
+ const hw = visibleWidth(hint);
626
+ const needed = curW === 0 ? hw : gapW + hw;
627
+ if (curW > 0 && curW + needed > maxW) {
628
+ lines.push(row(fg(t.hint, curLine)));
629
+ curLine = hint;
630
+ curW = hw;
631
+ } else {
632
+ curLine += (curW > 0 ? gap : "") + hint;
633
+ curW += needed;
634
+ }
635
+ }
636
+ if (curLine) lines.push(row(fg(t.hint, curLine)));
637
+
638
+ lines.push(fg(t.border, "╰" + "─".repeat(innerW) + "╯"));
639
+
640
+ return lines;
641
+ }
642
+
643
+ private renderServerRow(server: ServerState, isCursor: boolean): string {
644
+ const t = this.t;
645
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
646
+
647
+ const expandIcon = server.expanded ? "▾" : "▸";
648
+ const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·");
649
+
650
+ const nameStr = isCursor ? bold(fg(t.selected, server.name)) : server.name;
651
+ const importLabel = server.source === "import" ? fg(t.description, ` (${server.importKind ?? "import"})`) : "";
652
+
653
+ if (!server.hasCachedData) {
654
+ return `${prefix} ${nameStr}${importLabel} ${fg(t.description, "(not cached)")}`;
655
+ }
656
+
657
+ const directCount = server.tools.filter((t) => t.isDirect).length;
658
+ const totalCount = server.tools.length;
659
+ let toggleIcon = fg(t.description, "○");
660
+ if (directCount === totalCount && totalCount > 0) {
661
+ toggleIcon = fg(t.direct, "●");
662
+ } else if (directCount > 0) {
663
+ toggleIcon = fg(t.needsAuth, "◐");
664
+ }
665
+
666
+ let toolInfo = "";
667
+ if (totalCount > 0) {
668
+ toolInfo = `${directCount}/${totalCount}`;
669
+ if (directCount > 0) {
670
+ const tokens = server.tools.filter((t) => t.isDirect).reduce((s, t) => s + t.estimatedTokens, 0);
671
+ toolInfo += ` ~${tokens.toLocaleString()}`;
672
+ }
673
+ toolInfo = fg(t.description, toolInfo);
674
+ }
675
+
676
+ return `${prefix} ${toggleIcon} ${nameStr}${importLabel} ${toolInfo}`;
677
+ }
678
+
679
+ private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string {
680
+ const t = this.t;
681
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
682
+
683
+ const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○");
684
+ const cursor = isCursor ? fg(t.selected, "▸") : " ";
685
+ const nameStr = isCursor ? bold(fg(t.selected, tool.name)) : tool.name;
686
+
687
+ const prefixLen = 7 + visibleWidth(tool.name);
688
+ const maxDescLen = Math.max(0, innerW - prefixLen - 8);
689
+ const descStr =
690
+ maxDescLen > 5 && tool.description
691
+ ? fg(t.description, "— " + truncateToWidth(tool.description, maxDescLen, "…"))
692
+ : "";
693
+
694
+ return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}`;
695
+ }
696
+
697
+ invalidate(): void {}
698
+
699
+ dispose(): void {
700
+ this.cleanup();
701
+ }
702
+ }
703
+
704
+ export function createMcpPanel(
705
+ config: McpConfig,
706
+ cache: MetadataCache | null,
707
+ provenance: Map<string, ServerProvenance>,
708
+ callbacks: McpPanelCallbacks,
709
+ tui: { requestRender(): void },
710
+ done: (result: McpPanelResult) => void,
711
+ ): McpPanel & { dispose(): void } {
712
+ return new McpPanel(config, cache, provenance, callbacks, tui, done);
713
+ }