@aexol/spectral 0.2.5 → 0.2.7

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