lsd-pi 1.3.9 → 1.3.10

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 (46) hide show
  1. package/dist/resources/extensions/mcp-client/index.js +191 -83
  2. package/dist/resources/extensions/mcp-client/mcp-manager-component.js +220 -0
  3. package/dist/resources/extensions/slash-commands/plan.js +67 -13
  4. package/dist/resources/extensions/subagent/agents.js +7 -0
  5. package/dist/resources/extensions/subagent/index.js +25 -8
  6. package/dist/resources/extensions/subagent/model-resolution.js +1 -0
  7. package/package.json +1 -1
  8. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +104 -2
  9. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -1
  10. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +39 -2
  11. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  12. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +135 -18
  13. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  14. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +21 -2
  15. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -1
  16. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +146 -8
  17. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -1
  18. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +51 -13
  19. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -1
  20. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  21. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +75 -4
  22. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  23. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +4 -0
  24. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  25. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  26. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  27. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  28. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +31 -2
  29. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  30. package/packages/pi-coding-agent/package.json +1 -1
  31. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +129 -2
  32. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +158 -18
  33. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +163 -9
  34. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +60 -13
  35. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +86 -5
  36. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  37. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +31 -2
  38. package/pkg/package.json +1 -1
  39. package/src/resources/extensions/mcp-client/index.ts +212 -90
  40. package/src/resources/extensions/mcp-client/mcp-manager-component.ts +256 -0
  41. package/src/resources/extensions/mcp-client/tests/mcp-manager-component.test.ts +141 -0
  42. package/src/resources/extensions/mcp-client/tests/server-name-spaces.test.ts +18 -2
  43. package/src/resources/extensions/slash-commands/plan.ts +70 -13
  44. package/src/resources/extensions/subagent/agents.ts +9 -0
  45. package/src/resources/extensions/subagent/index.ts +30 -8
  46. package/src/resources/extensions/subagent/model-resolution.ts +1 -0
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * MCP Client Extension — Native MCP server integration for pi
3
3
  *
4
- * Provides on-demand access to MCP servers configured in project files
5
- * (.mcp.json, .lsd/mcp.json, with legacy .gsd/mcp.json fallback) using the
4
+ * Provides on-demand access to MCP servers configured in global (~/.lsd/mcp.json)
5
+ * and project files (.mcp.json, .lsd/mcp.json, with legacy .gsd/mcp.json fallback) using the
6
6
  * @modelcontextprotocol/sdk Client directly — no external CLI dependency
7
7
  * required.
8
8
  *
@@ -18,7 +18,9 @@ import { Client } from "@modelcontextprotocol/sdk/client";
18
18
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
19
19
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
20
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
21
22
  import { basename, dirname, join } from "node:path";
23
+ import { McpManagerComponent } from "./mcp-manager-component.js";
22
24
  // ─── Connection Manager ───────────────────────────────────────────────────────
23
25
  const connections = new Map();
24
26
  let configCache = null;
@@ -99,6 +101,7 @@ function readConfigs() {
99
101
  join(process.cwd(), ".mcp.json"),
100
102
  join(process.cwd(), ".lsd", "mcp.json"),
101
103
  join(process.cwd(), ".gsd", "mcp.json"),
104
+ join(homedir(), ".lsd", "mcp.json"),
102
105
  ];
103
106
  for (const configPath of configPaths) {
104
107
  try {
@@ -177,8 +180,6 @@ async function getOrConnect(name, signal) {
177
180
  throw new Error(`Unknown MCP server: "${name}". Use mcp_servers to list available servers.`);
178
181
  if (!config.enabled)
179
182
  throw new Error(`Server "${config.name}" is disabled. Use /mcp enable ${config.name}.`);
180
- // Always use config.name as the canonical cache key so that variant
181
- // casing / whitespace still hits the same connection.
182
183
  const existing = connections.get(config.name);
183
184
  if (existing)
184
185
  return existing.client;
@@ -206,6 +207,97 @@ async function getOrConnect(name, signal) {
206
207
  connections.set(config.name, { client, transport });
207
208
  return client;
208
209
  }
210
+ function mapToolSchemas(tools) {
211
+ return tools.map((tool) => ({
212
+ name: tool.name,
213
+ description: tool.description ?? "",
214
+ inputSchema: tool.inputSchema,
215
+ }));
216
+ }
217
+ function shouldRetryMcpOperation(error) {
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ if (/unknown mcp server/i.test(message))
220
+ return false;
221
+ if (/is disabled/i.test(message))
222
+ return false;
223
+ if (/unsupported transport/i.test(message))
224
+ return false;
225
+ if (/abort|cancel/i.test(message))
226
+ return false;
227
+ return true;
228
+ }
229
+ async function listServerTools(name, signal, options) {
230
+ const canonicalName = getCanonicalServerName(name);
231
+ if (options?.useCache !== false) {
232
+ const cached = toolCache.get(canonicalName);
233
+ if (cached) {
234
+ return { canonicalName, tools: cached, cached: true };
235
+ }
236
+ }
237
+ let attempt = 0;
238
+ while (attempt < 2) {
239
+ try {
240
+ if (attempt === 0 && options?.forceReconnect) {
241
+ await closeServerConnection(canonicalName);
242
+ }
243
+ if (attempt > 0) {
244
+ await closeServerConnection(canonicalName);
245
+ }
246
+ const client = await getOrConnect(canonicalName, signal);
247
+ const result = await client.listTools(undefined, { signal, timeout: 30000 });
248
+ const tools = mapToolSchemas(result.tools ?? []);
249
+ toolCache.set(canonicalName, tools);
250
+ return { canonicalName, tools, cached: false };
251
+ }
252
+ catch (error) {
253
+ attempt += 1;
254
+ await closeServerConnection(canonicalName);
255
+ if (attempt >= 2 || !shouldRetryMcpOperation(error)) {
256
+ throw error;
257
+ }
258
+ }
259
+ }
260
+ throw new Error(`Failed to list tools for ${canonicalName}`);
261
+ }
262
+ async function callServerTool(serverName, toolName, args, signal) {
263
+ const canonicalServer = getCanonicalServerName(serverName);
264
+ let attempt = 0;
265
+ while (attempt < 2) {
266
+ try {
267
+ if (attempt > 0) {
268
+ await closeServerConnection(canonicalServer);
269
+ }
270
+ const client = await getOrConnect(canonicalServer, signal);
271
+ const result = await client.callTool({ name: toolName, arguments: args }, undefined, { signal, timeout: 60000 });
272
+ return { canonicalServer, result };
273
+ }
274
+ catch (error) {
275
+ attempt += 1;
276
+ await closeServerConnection(canonicalServer);
277
+ if (attempt >= 2 || !shouldRetryMcpOperation(error)) {
278
+ throw error;
279
+ }
280
+ }
281
+ }
282
+ throw new Error(`Failed to call ${canonicalServer}.${toolName}`);
283
+ }
284
+ async function warmupServer(name, signal) {
285
+ try {
286
+ const { canonicalName, tools } = await listServerTools(name, signal, { useCache: false });
287
+ return {
288
+ name: canonicalName,
289
+ status: "connected",
290
+ toolCount: tools.length,
291
+ };
292
+ }
293
+ catch (error) {
294
+ return {
295
+ name: getCanonicalServerName(name),
296
+ status: "error",
297
+ error: error instanceof Error ? error.message : String(error),
298
+ };
299
+ }
300
+ }
209
301
  async function warmupEnabledServers() {
210
302
  if (warmupPromise)
211
303
  return warmupPromise;
@@ -213,31 +305,7 @@ async function warmupEnabledServers() {
213
305
  const enabledServers = readConfigs().filter((server) => server.enabled);
214
306
  if (enabledServers.length === 0)
215
307
  return [];
216
- const results = await Promise.allSettled(enabledServers.map(async (server) => {
217
- const client = await getOrConnect(server.name);
218
- const result = await client.listTools(undefined, { timeout: 30000 });
219
- const tools = (result.tools ?? []).map((tool) => ({
220
- name: tool.name,
221
- description: tool.description ?? "",
222
- inputSchema: tool.inputSchema,
223
- }));
224
- toolCache.set(server.name, tools);
225
- return {
226
- name: server.name,
227
- status: "connected",
228
- toolCount: tools.length,
229
- };
230
- }));
231
- return results.map((result, index) => {
232
- if (result.status === "fulfilled") {
233
- return result.value;
234
- }
235
- return {
236
- name: enabledServers[index]?.name ?? `server-${index + 1}`,
237
- status: "error",
238
- error: result.reason instanceof Error ? result.reason.message : String(result.reason),
239
- };
240
- });
308
+ return Promise.all(enabledServers.map((server) => warmupServer(server.name)));
241
309
  })();
242
310
  try {
243
311
  return await warmupPromise;
@@ -264,10 +332,41 @@ async function reloadMcpState() {
264
332
  await closeAll();
265
333
  configCache = null;
266
334
  }
335
+ function getSourceLabel(sourcePath) {
336
+ if (!sourcePath)
337
+ return "";
338
+ return sourcePath.startsWith(homedir()) ? "global" : "project";
339
+ }
340
+ function getManagerServerInfo() {
341
+ return readConfigs().map((server) => ({
342
+ name: server.name,
343
+ enabled: server.enabled,
344
+ connected: connections.has(server.name),
345
+ transport: server.transport,
346
+ toolCount: toolCache.get(server.name)?.length ?? 0,
347
+ sourceLabel: getSourceLabel(server.sourcePath),
348
+ }));
349
+ }
267
350
  // ─── Formatters ───────────────────────────────────────────────────────────────
268
351
  function formatServerList(servers) {
269
- if (servers.length === 0)
270
- return "No MCP servers configured. Add servers to .mcp.json or .lsd/mcp.json.";
352
+ if (servers.length === 0) {
353
+ return [
354
+ "No MCP servers configured.\n",
355
+ "Configuration guide:",
356
+ " Global (all projects): ~/.lsd/mcp.json",
357
+ " Project-level: .mcp.json or .lsd/mcp.json\n",
358
+ 'Example ~/.lsd/mcp.json:',
359
+ '{',
360
+ ' "mcpServers": {',
361
+ ' "my-server": {',
362
+ ' "command": "path/to/server",',
363
+ ' "args": ["--working-dir", "."]',
364
+ ' }',
365
+ ' }',
366
+ '}\n',
367
+ "After editing, run: /mcp reload",
368
+ ].join("\n");
369
+ }
271
370
  const lines = ["MCP servers\n"];
272
371
  for (const s of servers) {
273
372
  const connected = connections.has(s.name) ? "yes" : "no";
@@ -278,11 +377,13 @@ function formatServerList(servers) {
278
377
  lines.push(` connected: ${connected}`);
279
378
  lines.push(` transport: ${s.transport}`);
280
379
  lines.push(` tools: ${tools}`);
281
- if (s.sourcePath)
282
- lines.push(` source: ${basename(s.sourcePath)}`);
380
+ if (s.sourcePath) {
381
+ lines.push(` source: ${getSourceLabel(s.sourcePath)} — ${basename(s.sourcePath)}`);
382
+ }
283
383
  lines.push("");
284
384
  }
285
385
  lines.push("Hints:");
386
+ lines.push(" /mcp");
286
387
  lines.push(" /mcp inspect <server>");
287
388
  lines.push(" /mcp enable <server>");
288
389
  lines.push(" /mcp disable <server>");
@@ -319,6 +420,39 @@ function formatMcpCommandHelp() {
319
420
  " /mcp reload",
320
421
  ].join("\n");
321
422
  }
423
+ async function openMcpManager(ctx) {
424
+ await ctx.ui.custom((tui, theme, _keybindings, done) => new McpManagerComponent({
425
+ getServers: () => getManagerServerInfo(),
426
+ onToggle: async (name) => {
427
+ const config = getServerConfig(name);
428
+ if (!config)
429
+ return null;
430
+ const result = await setServerEnabled(name, !config.enabled);
431
+ const updated = getServerConfig(result.canonicalName);
432
+ if (updated?.enabled) {
433
+ await warmupServer(updated.name);
434
+ }
435
+ return getManagerServerInfo().find((server) => server.name === result.canonicalName) ?? null;
436
+ },
437
+ onInspect: async (name) => {
438
+ const { canonicalName, tools } = await listServerTools(name, undefined, { useCache: true });
439
+ return formatToolList(canonicalName, tools);
440
+ },
441
+ onReconnect: async (name) => {
442
+ const { canonicalName } = await listServerTools(name, undefined, { forceReconnect: true, useCache: false });
443
+ return getManagerServerInfo().find((server) => server.name === canonicalName) ?? null;
444
+ },
445
+ onClose: () => done(undefined),
446
+ requestRender: () => tui.requestRender(),
447
+ }, theme), {
448
+ overlay: true,
449
+ overlayOptions: {
450
+ width: "80%",
451
+ maxHeight: "70%",
452
+ anchor: "center",
453
+ },
454
+ });
455
+ }
322
456
  async function handleMcpCommand(args, ctx) {
323
457
  const trimmed = args.trim();
324
458
  const parts = trimmed.split(/\s+/).filter(Boolean);
@@ -339,20 +473,8 @@ async function handleMcpCommand(args, ctx) {
339
473
  return;
340
474
  }
341
475
  const canonicalName = config.name;
342
- const cached = toolCache.get(canonicalName);
343
- if (cached) {
344
- ctx.ui.notify(formatToolList(canonicalName, cached), "info");
345
- return;
346
- }
347
476
  try {
348
- const client = await getOrConnect(canonicalName);
349
- const result = await client.listTools(undefined, { timeout: 30000 });
350
- const tools = (result.tools ?? []).map((tool) => ({
351
- name: tool.name,
352
- description: tool.description ?? "",
353
- inputSchema: tool.inputSchema,
354
- }));
355
- toolCache.set(canonicalName, tools);
477
+ const { tools } = await listServerTools(canonicalName, undefined, { useCache: true });
356
478
  ctx.ui.notify(formatToolList(canonicalName, tools), "info");
357
479
  }
358
480
  catch (error) {
@@ -374,9 +496,8 @@ async function handleMcpCommand(args, ctx) {
374
496
  const changeText = result.changed ? action : `already ${action}`;
375
497
  ctx.ui.notify(`MCP server ${result.canonicalName} ${changeText}.`, "info");
376
498
  if (enabled) {
377
- const warmupResults = await warmupEnabledServers();
378
- const warmupResult = warmupResults.find((entry) => entry.name === result.canonicalName);
379
- if (warmupResult?.status === "error") {
499
+ const warmupResult = await warmupServer(result.canonicalName);
500
+ if (warmupResult.status === "error") {
380
501
  ctx.ui.notify(`Failed to connect ${result.canonicalName}: ${warmupResult.error}`, "error");
381
502
  }
382
503
  }
@@ -446,6 +567,10 @@ export default function (pi) {
446
567
  return [];
447
568
  },
448
569
  handler: async (args, ctx) => {
570
+ if (!args.trim() && typeof ctx.ui.custom === "function") {
571
+ await openMcpManager(ctx);
572
+ return;
573
+ }
449
574
  await handleMcpCommand(args, ctx);
450
575
  },
451
576
  });
@@ -453,7 +578,8 @@ export default function (pi) {
453
578
  pi.registerTool({
454
579
  name: "mcp_servers",
455
580
  label: "MCP Servers",
456
- description: "List all available MCP servers configured in project files (.mcp.json, .lsd/mcp.json, legacy .gsd/mcp.json). " +
581
+ description: "List all available MCP servers from global (~/.lsd/mcp.json) and project-level " +
582
+ "(.mcp.json, .lsd/mcp.json, legacy .gsd/mcp.json) config files. " +
457
583
  "Shows server names, transport type, and connection status. Use mcp_discover to get full tool schemas for a server.",
458
584
  promptSnippet: "List available MCP servers from project configuration",
459
585
  promptGuidelines: [
@@ -510,29 +636,7 @@ export default function (pi) {
510
636
  }),
511
637
  async execute(_id, params, signal) {
512
638
  try {
513
- const canonicalServer = getCanonicalServerName(params.server);
514
- // Return cached tools if available
515
- const cached = toolCache.get(canonicalServer);
516
- if (cached) {
517
- const text = formatToolList(canonicalServer, cached);
518
- const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
519
- let finalText = truncation.content;
520
- if (truncation.truncated) {
521
- finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
522
- }
523
- return {
524
- content: [{ type: "text", text: finalText }],
525
- details: { server: canonicalServer, toolCount: cached.length, cached: true },
526
- };
527
- }
528
- const client = await getOrConnect(canonicalServer, signal);
529
- const result = await client.listTools(undefined, { signal, timeout: 30000 });
530
- const tools = (result.tools ?? []).map((t) => ({
531
- name: t.name,
532
- description: t.description ?? "",
533
- inputSchema: t.inputSchema,
534
- }));
535
- toolCache.set(canonicalServer, tools);
639
+ const { canonicalName: canonicalServer, tools, cached } = await listServerTools(params.server, signal, { useCache: true });
536
640
  const text = formatToolList(canonicalServer, tools);
537
641
  const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
538
642
  let finalText = truncation.content;
@@ -541,7 +645,7 @@ export default function (pi) {
541
645
  }
542
646
  return {
543
647
  content: [{ type: "text", text: finalText }],
544
- details: { server: canonicalServer, toolCount: tools.length, cached: false },
648
+ details: { server: canonicalServer, toolCount: tools.length, cached },
545
649
  };
546
650
  }
547
651
  catch (err) {
@@ -588,10 +692,7 @@ export default function (pi) {
588
692
  }),
589
693
  async execute(_id, params, signal) {
590
694
  try {
591
- const canonicalServer = getCanonicalServerName(params.server);
592
- const client = await getOrConnect(canonicalServer, signal);
593
- const result = await client.callTool({ name: params.tool, arguments: params.args ?? {} }, undefined, { signal, timeout: 60000 });
594
- // Serialize result content to text
695
+ const { canonicalServer, result } = await callServerTool(params.server, params.tool, params.args ?? {}, signal);
595
696
  const contentItems = result.content;
596
697
  const raw = contentItems
597
698
  .map((c) => (c.type === "text" ? c.text ?? "" : JSON.stringify(c)))
@@ -654,20 +755,27 @@ export default function (pi) {
654
755
  const servers = readConfigs();
655
756
  const enabledServers = servers.filter((server) => server.enabled);
656
757
  if (servers.length > 0) {
657
- ctx.ui.notify(`MCP client ready — ${enabledServers.length}/${servers.length} server(s) enabled`, "info");
758
+ ctx.ui.notify(`MCP client ready — ${enabledServers.length}/${servers.length} server(s) enabled, warming up…`, "info");
658
759
  }
659
760
  if (enabledServers.length === 0)
660
761
  return;
661
- void warmupEnabledServers().then((results) => {
762
+ try {
763
+ const warmupTimeout = new Promise((_, reject) => setTimeout(() => reject(new Error("warmup timed out after 30s")), 30_000));
764
+ const results = await Promise.race([warmupEnabledServers(), warmupTimeout]);
765
+ const succeeded = results.filter((entry) => entry.status === "connected");
662
766
  const failed = results.filter((entry) => entry.status === "error");
767
+ if (succeeded.length > 0) {
768
+ ctx.ui.notify(`MCP autoconnect complete — ${succeeded.length} server(s) connected`, "success");
769
+ }
663
770
  if (failed.length > 0) {
664
771
  const failureSummary = failed.map((entry) => `${entry.name}: ${entry.error}`).join("; ");
665
772
  ctx.ui.notify(`MCP autoconnect partial failure — ${failureSummary}`, "warning");
666
773
  }
667
- }).catch((error) => {
774
+ }
775
+ catch (error) {
668
776
  const message = error instanceof Error ? error.message : String(error);
669
777
  ctx.ui.notify(`MCP autoconnect failed: ${message}`, "warning");
670
- });
778
+ }
671
779
  });
672
780
  pi.on("session_shutdown", async () => {
673
781
  warmupPromise = null;
@@ -675,6 +783,6 @@ export default function (pi) {
675
783
  });
676
784
  pi.on("session_switch", async () => {
677
785
  await reloadMcpState();
678
- void warmupEnabledServers();
786
+ await warmupEnabledServers();
679
787
  });
680
788
  }
@@ -0,0 +1,220 @@
1
+ import { Key, SelectList, matchesKey, truncateToWidth } from "@gsd/pi-tui";
2
+ function getSelectListTheme(theme) {
3
+ return {
4
+ selectedPrefix: (text) => theme.fg("accent", text),
5
+ selectedText: (text) => theme.fg("accent", text),
6
+ description: (text) => theme.fg("muted", text),
7
+ scrollInfo: (text) => theme.fg("dim", text),
8
+ noMatch: (text) => theme.fg("warning", text),
9
+ };
10
+ }
11
+ function serversToItems(servers) {
12
+ return servers.map((server) => ({
13
+ value: server.name,
14
+ label: server.name,
15
+ description: [
16
+ server.enabled ? "enabled" : "disabled",
17
+ server.transport,
18
+ server.connected ? "● connected" : "○ offline",
19
+ `${server.toolCount} tools`,
20
+ server.sourceLabel || undefined,
21
+ ].filter(Boolean).join(" "),
22
+ }));
23
+ }
24
+ export class McpManagerComponent {
25
+ theme;
26
+ callbacks;
27
+ selectList;
28
+ mode = "list";
29
+ inspectServerName = "";
30
+ inspectLines = [];
31
+ inspectScrollOffset = 0;
32
+ statusMessage = "";
33
+ busy = false;
34
+ statusTimeout = null;
35
+ constructor(callbacks, theme) {
36
+ this.callbacks = callbacks;
37
+ this.theme = theme;
38
+ this.selectList = new SelectList([], 8, getSelectListTheme(theme));
39
+ this.bindSelectList();
40
+ this.refreshList();
41
+ }
42
+ invalidate() {
43
+ this.selectList.invalidate();
44
+ }
45
+ dispose() {
46
+ if (this.statusTimeout) {
47
+ clearTimeout(this.statusTimeout);
48
+ this.statusTimeout = null;
49
+ }
50
+ }
51
+ getMode() {
52
+ return this.mode;
53
+ }
54
+ handleInput(data) {
55
+ if (this.mode === "inspect") {
56
+ this.handleInspectInput(data);
57
+ return;
58
+ }
59
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
60
+ this.callbacks.onClose();
61
+ return;
62
+ }
63
+ if (data === "i") {
64
+ void this.handleInspect();
65
+ return;
66
+ }
67
+ if (data === "r") {
68
+ void this.handleReconnect();
69
+ return;
70
+ }
71
+ this.selectList.handleInput(data);
72
+ this.callbacks.requestRender();
73
+ }
74
+ render(width) {
75
+ const lines = [];
76
+ const add = (line = "") => lines.push(truncateToWidth(line, width));
77
+ const divider = this.theme.fg("border", "─".repeat(Math.max(width, 1)));
78
+ add(divider);
79
+ if (this.mode === "inspect") {
80
+ add(this.theme.bold(this.theme.fg("toolTitle", ` MCP Tools · ${this.inspectServerName}`)) +
81
+ this.theme.fg("dim", " esc/q: back ↑↓/pgup/pgdn/home/end: scroll"));
82
+ add("");
83
+ const bodyHeight = Math.max(8, width > 0 ? 18 : 8);
84
+ const maxOffset = Math.max(0, this.inspectLines.length - bodyHeight);
85
+ this.inspectScrollOffset = Math.max(0, Math.min(this.inspectScrollOffset, maxOffset));
86
+ const visibleLines = this.inspectLines.slice(this.inspectScrollOffset, this.inspectScrollOffset + bodyHeight);
87
+ for (const line of visibleLines)
88
+ add(line);
89
+ if (visibleLines.length === 0)
90
+ add(this.theme.fg("dim", " No tool information"));
91
+ add("");
92
+ add(divider);
93
+ add(this.theme.fg("dim", ` ${this.inspectLines.length} lines`));
94
+ return lines;
95
+ }
96
+ add(this.theme.bold(this.theme.fg("toolTitle", " MCP Servers")) +
97
+ this.theme.fg("dim", " ↑↓ navigate enter: toggle i: inspect r: reconnect esc: close"));
98
+ add("");
99
+ lines.push(...this.selectList.render(width));
100
+ add("");
101
+ add(divider);
102
+ const servers = this.callbacks.getServers();
103
+ const enabled = servers.filter((server) => server.enabled).length;
104
+ let footer = this.theme.fg("dim", ` ${servers.length} servers · ${enabled} enabled`);
105
+ if (this.busy)
106
+ footer += this.theme.fg("accent", " · working…");
107
+ if (this.statusMessage)
108
+ footer += this.theme.fg("accent", ` — ${this.statusMessage}`);
109
+ add(footer);
110
+ return lines;
111
+ }
112
+ bindSelectList() {
113
+ this.selectList.onSelect = () => {
114
+ void this.handleToggle();
115
+ };
116
+ this.selectList.onCancel = () => {
117
+ this.callbacks.onClose();
118
+ };
119
+ }
120
+ refreshList(preferredName) {
121
+ const currentSelected = preferredName ?? this.selectList.getSelectedItem()?.value;
122
+ this.selectList = new SelectList(serversToItems(this.callbacks.getServers()), 8, getSelectListTheme(this.theme));
123
+ this.bindSelectList();
124
+ if (currentSelected) {
125
+ const items = this.callbacks.getServers();
126
+ const index = items.findIndex((item) => item.name === currentSelected);
127
+ if (index >= 0)
128
+ this.selectList.setSelectedIndex(index);
129
+ }
130
+ this.callbacks.requestRender();
131
+ }
132
+ setStatus(message) {
133
+ this.statusMessage = message;
134
+ this.callbacks.requestRender();
135
+ if (this.statusTimeout)
136
+ clearTimeout(this.statusTimeout);
137
+ if (!message)
138
+ return;
139
+ this.statusTimeout = setTimeout(() => {
140
+ this.statusMessage = "";
141
+ this.callbacks.requestRender();
142
+ }, 3000);
143
+ this.statusTimeout.unref?.();
144
+ }
145
+ getSelectedName() {
146
+ return this.selectList.getSelectedItem()?.value;
147
+ }
148
+ async runBusy(task) {
149
+ if (this.busy)
150
+ return;
151
+ this.busy = true;
152
+ this.callbacks.requestRender();
153
+ try {
154
+ await task();
155
+ }
156
+ finally {
157
+ this.busy = false;
158
+ this.callbacks.requestRender();
159
+ }
160
+ }
161
+ async handleToggle() {
162
+ const name = this.getSelectedName();
163
+ if (!name)
164
+ return;
165
+ await this.runBusy(async () => {
166
+ this.setStatus(`Toggling ${name}...`);
167
+ const updated = await this.callbacks.onToggle(name);
168
+ this.refreshList(updated?.name ?? name);
169
+ if (updated) {
170
+ this.setStatus(`${updated.name}: ${updated.enabled ? "enabled" : "disabled"}`);
171
+ }
172
+ });
173
+ }
174
+ async handleInspect() {
175
+ const name = this.getSelectedName();
176
+ if (!name)
177
+ return;
178
+ await this.runBusy(async () => {
179
+ this.setStatus(`Loading tools for ${name}...`);
180
+ const text = await this.callbacks.onInspect(name);
181
+ this.inspectServerName = name;
182
+ this.inspectLines = text.split("\n");
183
+ this.inspectScrollOffset = 0;
184
+ this.mode = "inspect";
185
+ this.setStatus("");
186
+ });
187
+ }
188
+ async handleReconnect() {
189
+ const name = this.getSelectedName();
190
+ if (!name)
191
+ return;
192
+ await this.runBusy(async () => {
193
+ this.setStatus(`Reconnecting ${name}...`);
194
+ const updated = await this.callbacks.onReconnect(name);
195
+ this.refreshList(updated?.name ?? name);
196
+ this.setStatus(updated ? `${updated.name}: reconnected` : `${name}: reconnect failed`);
197
+ });
198
+ }
199
+ handleInspectInput(data) {
200
+ if (matchesKey(data, Key.escape) || data === "q") {
201
+ this.mode = "list";
202
+ this.callbacks.requestRender();
203
+ return;
204
+ }
205
+ const page = 12;
206
+ if (matchesKey(data, Key.up))
207
+ this.inspectScrollOffset -= 1;
208
+ else if (matchesKey(data, Key.down))
209
+ this.inspectScrollOffset += 1;
210
+ else if (matchesKey(data, Key.pageUp))
211
+ this.inspectScrollOffset -= page;
212
+ else if (matchesKey(data, Key.pageDown))
213
+ this.inspectScrollOffset += page;
214
+ else if (matchesKey(data, Key.home))
215
+ this.inspectScrollOffset = 0;
216
+ else if (matchesKey(data, Key.end))
217
+ this.inspectScrollOffset = Number.MAX_SAFE_INTEGER;
218
+ this.callbacks.requestRender();
219
+ }
220
+ }