decorated-pi 0.5.4 → 0.6.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 (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /package/{extensions → tools}/lsp/protocol.ts +0 -0
@@ -1,53 +1,19 @@
1
1
  /**
2
- * IOReplace Pi native edit/write with `patch` tool (single-file)
2
+ * patchexact string replacement tool.
3
3
  *
4
- * Keeps: read (for stale-read protection via mtime tracking)
5
- * Removes: edit, write
6
- * Adds: patch (old_str/new_str exact replacement, single file per call)
7
- *
8
- * Schema: { path, edits?, overwrite?, new_str? }
9
- * Pi’s native parallel tool calls handle multi-file scenarios.
10
- *
11
- * Stale-read protection:
12
- * - `read` tool records file mtime when LLM reads a file
13
- * - `patch` tool checks: if file mtime > last-read mtime → reject
14
- * - `patch` tool updates mtime after successful write
15
- *
16
- * TUI Rendering Pitfalls (learned the hard way):
17
- * 1. execute() MUST throw errors, NOT return { isError: true }
18
- * 2. TUI rendering MUST mirror the edit tool pattern exactly
19
- * 3. getPatchHeaderBg: settledError MUST be checked first
20
- * 4. renderResult must NOT return the Box
21
- * renderCall returns the Box (callComponent). If renderResult
22
- * also returns it, pi's ToolExecutionComponent adds it twice
23
- * to the container, causing duplicate boxes. renderResult
24
- * must return context.lastComponent (a separate Container).
25
- * 5. Error text must go INSIDE the Box, not in the result Container
26
- * 6. prepareArguments must handle literal newlines in JSON strings
4
+ * Replaces pi's native edit/write. Stale-read protection and mtime tracking
5
+ * are in hooks/track-mtime.ts (this tool does not register hooks itself).
27
6
  */
28
7
 
29
- import { defineTool, isEditToolResult, isReadToolResult, isWriteToolResult, keyHint, type ExtensionAPI, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
8
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
9
  import { renderDiff } from "@earendil-works/pi-coding-agent";
31
10
  import { Box, Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
32
- import { writeOutputToTemp } from "./io-tool-output.js";
33
11
  import { Type } from "typebox";
34
12
  import {
35
13
  applyPatch,
36
- formatPatchResult,
37
14
  generatePatchDiff,
38
15
  type PatchPreview,
39
- } from "./patch.js";
40
- import {
41
- recordReadTime,
42
- checkStaleFile,
43
- clearReadMarkers,
44
- resolveAbsolutePath,
45
- FILE_TIMES_CUSTOM_TYPE,
46
- createFileTimeMarkerData,
47
- restoreReadMarkersFromBranch,
48
- } from "./file-times.js";
49
-
50
- // ─── Schema ─────────────────────────────────────────────────────────────────────────
16
+ } from "./core.js";
51
17
 
52
18
  const EditSchema = Type.Object({
53
19
  anchor: Type.Optional(Type.String({
@@ -70,8 +36,6 @@ const PatchSchema = Type.Object({
70
36
  }),
71
37
  });
72
38
 
73
- // ─── Argument repair ───────────────────────────────────────────────────────────────
74
-
75
39
  function fixJsonNewlines(str: string): string {
76
40
  let result = '';
77
41
  let inString = false;
@@ -97,18 +61,13 @@ function jsonParseWithNewlineFix(str: string): any {
97
61
 
98
62
  export function preparePatchArguments(input: any): any {
99
63
  if (!input || typeof input !== "object") return input;
100
-
101
64
  const args = input as Record<string, any>;
102
-
103
- // Edits serialized as JSON string
104
65
  if (typeof args.edits === "string") {
105
66
  try {
106
67
  const parsed = jsonParseWithNewlineFix(args.edits);
107
68
  if (Array.isArray(parsed)) args.edits = parsed;
108
69
  } catch { /* keep original */ }
109
70
  }
110
-
111
- // Legacy: top-level old_str/new_str instead of edits array
112
71
  if (typeof args.old_str === "string" && typeof args.new_str === "string") {
113
72
  const edit: any = { old_str: args.old_str, new_str: args.new_str };
114
73
  if (typeof args.anchor === "string") edit.anchor = args.anchor;
@@ -117,12 +76,9 @@ export function preparePatchArguments(input: any): any {
117
76
  delete args.new_str;
118
77
  delete args.anchor;
119
78
  }
120
-
121
79
  return args;
122
80
  }
123
81
 
124
- // ─── TUI rendering ─────────────────────────────────────────────────────────────────
125
-
126
82
  interface PatchCallComponent extends Box {
127
83
  preview?: PatchPreview;
128
84
  previewArgsKey?: string;
@@ -156,13 +112,9 @@ function replaceTabs(text: string): string {
156
112
  }
157
113
 
158
114
  function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
159
- if (component.settledError) {
160
- return (text: string) => theme.bg("toolErrorBg", text);
161
- }
115
+ if (component.settledError) return (text: string) => theme.bg("toolErrorBg", text);
162
116
  if (component.preview) {
163
- if ("error" in component.preview && component.preview.error) {
164
- return (text: string) => theme.bg("toolErrorBg", text);
165
- }
117
+ if ("error" in component.preview && component.preview.error) return (text: string) => theme.bg("toolErrorBg", text);
166
118
  return (text: string) => theme.bg("toolSuccessBg", text);
167
119
  }
168
120
  return (text: string) => theme.bg("toolPendingBg", text);
@@ -170,9 +122,7 @@ function getPatchHeaderBg(component: PatchCallComponent, theme: any) {
170
122
 
171
123
  function createSingleLineComponent(text: string) {
172
124
  return {
173
- render(width: number) {
174
- return [truncateToWidth(text, width)];
175
- },
125
+ render(width: number) { return [truncateToWidth(text, width)]; },
176
126
  invalidate() {},
177
127
  };
178
128
  }
@@ -188,39 +138,23 @@ function formatPatchMetaLine(line: string, theme: any): string {
188
138
  function appendPatchDiffChildren(parent: Box, body: string, theme: any): void {
189
139
  const rawLines = body.split("\n");
190
140
  let buffer: string[] = [];
191
-
192
141
  const flush = () => {
193
142
  if (buffer.length === 0) return;
194
143
  parent.addChild(new Text(renderDiff(replaceTabs(buffer.join("\n"))), 0, 0));
195
144
  buffer = [];
196
145
  };
197
-
198
146
  for (const line of rawLines) {
199
- if (line.startsWith("@@ lines ")) {
200
- flush();
201
- parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
202
- continue;
203
- }
204
- if (line === "anchors:") {
205
- flush();
206
- parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
207
- continue;
208
- }
209
- if (line.startsWith(" - ")) {
210
- flush();
211
- parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any);
212
- continue;
213
- }
147
+ if (line.startsWith("@@ lines ")) { flush(); parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any); continue; }
148
+ if (line === "anchors:") { flush(); parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any); continue; }
149
+ if (line.startsWith(" - ")) { flush(); parent.addChild(createSingleLineComponent(formatPatchMetaLine(line, theme)) as any); continue; }
214
150
  buffer.push(line);
215
151
  }
216
-
217
152
  flush();
218
153
  }
219
154
 
220
- export function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
155
+ function buildPatchCallComponent(component: PatchCallComponent, args: any, theme: any, expanded = false) {
221
156
  component.setBgFn(getPatchHeaderBg(component, theme));
222
157
  component.clear();
223
-
224
158
  let label = "";
225
159
  if (args?.path) {
226
160
  label = theme.fg("accent", args.path);
@@ -230,115 +164,37 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
230
164
  }
231
165
  const headerText = theme.fg("toolTitle", theme.bold("patch")) + (label ? " " + label : "");
232
166
  component.addChild(new Text(headerText, 0, 0));
233
-
234
167
  if (component.settledError || !component.preview) return component;
235
-
236
168
  const preview = component.preview;
237
169
  let body = "";
238
- if ("diff" in preview && preview.diff) {
239
- body = preview.diff;
240
- } else if ("error" in preview && preview.error) {
170
+ if ("diff" in preview && preview.diff) body = preview.diff;
171
+ else if ("error" in preview && preview.error) {
241
172
  component.addChild(new Spacer(1));
242
173
  component.addChild(new Text(theme.fg("error", ` Error: ${preview.error}`), 0, 0));
243
174
  return component;
244
175
  }
245
-
246
- if (!body) return component;
247
-
176
+ if (!body) {
177
+ component.addChild(new Spacer(1));
178
+ component.addChild(new Text(theme.fg("dim", ` (no changes)`), 0, 0));
179
+ return component;
180
+ }
248
181
  const lines = body.split("\n");
249
182
  const FOLD_THRESHOLD = 45;
250
-
251
183
  component.addChild(new Spacer(1));
252
-
253
184
  if (lines.length > FOLD_THRESHOLD && !expanded) {
254
- // 折叠态: 显示前几行 + 摘要
255
185
  const shown = lines.slice(0, 10).join("\n");
256
186
  appendPatchDiffChildren(component, shown, theme);
257
187
  component.addChild(new Text(
258
- theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + keyHint("app.tools.expand", "expand") + theme.fg("dim", ")"),
188
+ theme.fg("dim", ` ... ${lines.length - 10} more lines (`) + "(expand)" + theme.fg("dim", ")"),
259
189
  0, 0,
260
190
  ));
261
191
  } else {
262
- // 展开态: 完整显示
263
192
  appendPatchDiffChildren(component, body, theme);
264
193
  }
265
-
266
194
  return component;
267
195
  }
268
196
 
269
- // ─── Setup ──────────────────────────────────────────────────────────────────────────
270
-
271
- export const OUTPUT_EXTERNALIZE_THRESHOLD = 30_000; // 30KB — match Claude Code's bash truncation threshold
272
-
273
- /** If the tool result content is a single text string longer than the
274
- * threshold, replace it with a one-line placeholder pointing at the
275
- * full output on disk. Returns the modified result, or undefined to
276
- * leave the original content untouched. */
277
- export function maybeExternalizeToolResult(event: ToolResultEvent): ToolResultEventResult | undefined {
278
- const content = event.content;
279
- if (!Array.isArray(content) || content.length === 0) return undefined;
280
- const first = content[0];
281
- if (first?.type !== "text" || typeof first.text !== "string") return undefined;
282
- const text = first.text;
283
- if (text.length <= OUTPUT_EXTERNALIZE_THRESHOLD) return undefined;
284
-
285
- const filePath = writeOutputToTemp(event.toolName, event.toolCallId, text);
286
- if (!filePath) return undefined; // write failed — keep original content
287
-
288
- return {
289
- content: [{
290
- type: "text" as const,
291
- text: `[Output truncated: ${text.length.toLocaleString()} chars. Full output: ${filePath}]`,
292
- }],
293
- };
294
- }
295
-
296
- export function setupIO(pi: ExtensionAPI) {
297
- pi.on("session_start", (_event, ctx) => {
298
- restoreReadMarkersFromBranch(ctx.sessionManager.getBranch() as any[], ctx.cwd);
299
- const active = pi.getActiveTools();
300
- // Remove: edit (replaced by patch), grep/find/ls (replaced by bash)
301
- // Keep: write (full-file write)
302
- pi.setActiveTools(active.filter(t => !["edit", "grep", "find", "ls"].includes(t)));
303
- });
304
-
305
- pi.on("session_compact", () => {
306
- clearReadMarkers();
307
- });
308
-
309
- // Externalize large tool results (read / bash) to a temp file.
310
- // Keeps the messages segment small so prompt cache stays warm across turns.
311
- pi.on("tool_result", (event) => {
312
- if (event.toolName !== "read" && event.toolName !== "bash") return;
313
- return maybeExternalizeToolResult(event);
314
- });
315
-
316
- // Track file read times
317
- pi.on("tool_result", (event, ctx) => {
318
- if (!isReadToolResult(event)) return;
319
- const filePath = event.input?.path;
320
- if (typeof filePath !== "string" || !filePath.trim()) return;
321
- const cwd: string = ctx.cwd ?? process.cwd();
322
- const absPath = resolveAbsolutePath(cwd, filePath);
323
- recordReadTime(absPath);
324
- const marker = createFileTimeMarkerData(cwd, absPath);
325
- if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
326
- });
327
-
328
- // Track file write times (for write and edit, in case patch module is disabled)
329
- pi.on("tool_result", (event, ctx) => {
330
- if (!isWriteToolResult(event) && !isEditToolResult(event)) return;
331
- const filePath = event.input?.path;
332
- if (typeof filePath !== "string" || !filePath.trim()) return;
333
- const cwd: string = ctx.cwd ?? process.cwd();
334
- const absPath = resolveAbsolutePath(cwd, filePath);
335
- // Only record if the write succeeded (not an error result)
336
- if (event.isError) return;
337
- recordReadTime(absPath);
338
- const marker = createFileTimeMarkerData(cwd, absPath);
339
- if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
340
- });
341
-
197
+ export function registerPatchTool(pi: ExtensionAPI): void {
342
198
  pi.registerTool(defineTool({
343
199
  name: "patch",
344
200
  label: "Patch",
@@ -349,7 +205,7 @@ export function setupIO(pi: ExtensionAPI) {
349
205
  "Examples:",
350
206
  ' { path: "src/foo.ts", edits: [{ old_str: "return 1", new_str: "return 42" }] }',
351
207
  ' { path: "src/foo.ts", edits: [{ anchor: "function bar() {", old_str: "return x", new_str: "return x + 1" }] }',
352
- ' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\"debug\");", new_str: "// debug disabled" }] }',
208
+ ' { path: "src/foo.ts", edits: [{ anchor: "function init() {", old_str: "const DEBUG = true;", new_str: "const DEBUG = false;" }, { old_str: "log(\\"debug\\");", new_str: "// debug disabled" }] }',
353
209
  "",
354
210
  "Anchor (optional): narrows old_str search to lines after a unique marker.",
355
211
  " Code: use the enclosing definition — function/class/struct/method signature.",
@@ -368,36 +224,19 @@ export function setupIO(pi: ExtensionAPI) {
368
224
  prepareArguments: preparePatchArguments,
369
225
  execute: async (_toolCallId: string, input: { path: string; edits: any[] }, _signal: any, _onUpdate: any, ctx: any) => {
370
226
  const cwd: string = ctx.cwd ?? process.cwd();
371
-
372
- // Stale-read protection
373
- if (input.path?.trim()) {
374
- const absPath = resolveAbsolutePath(cwd, input.path);
375
- const staleError = checkStaleFile(absPath, input.path);
376
- if (staleError) throw new Error(staleError);
377
- }
378
-
227
+ // Stale-read check is in hooks/track-mtime.ts (tool_call phase).
228
+ // Just apply the patch here.
379
229
  const result = await applyPatch(input as any, cwd);
380
-
381
- // Update read markers after successful write
382
- for (const filePath of [...result.modified, ...result.created]) {
383
- const absPath = resolveAbsolutePath(cwd, filePath);
384
- recordReadTime(absPath);
385
- const marker = createFileTimeMarkerData(cwd, absPath);
386
- if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
387
- }
388
-
389
- const summary = formatPatchResult(result);
390
230
  const diff = generatePatchDiff(result);
391
- return {
392
- content: [{ type: "text", text: summary }],
393
- details: { diff },
394
- };
231
+ // Return "Success" on the LLM-facing channel — the model already knows
232
+ // what it asked to change (it sent the edits). The full diff stays in
233
+ // `details` for the TUI renderer. This keeps the prompt-cache segment
234
+ // stable: success always costs 1 token regardless of N hunks edited.
235
+ return { content: [{ type: "text", text: "Success" }], details: { diff } };
395
236
  },
396
-
397
237
  renderCall(args: any, theme: any, context: any) {
398
238
  const state = context.state;
399
239
  const component = getPatchCallComponent(state, context.lastComponent);
400
-
401
240
  const argsKey = args ? JSON.stringify(args) : undefined;
402
241
  if (component.previewArgsKey !== argsKey) {
403
242
  component.preview = undefined;
@@ -405,20 +244,12 @@ export function setupIO(pi: ExtensionAPI) {
405
244
  component.previewPending = false;
406
245
  component.settledError = false;
407
246
  }
408
-
409
- // Preview diff is computed during execute and delivered via result.details.diff.
410
- // Skipping async preview here avoids a redundant file read — same design as
411
- // Pi's native edit tool where renderResult overwrites the preview with execute's diff.
412
-
413
247
  return buildPatchCallComponent(component, args, theme, context.expanded);
414
248
  },
415
-
416
249
  renderResult(result: any, options: any, theme: any, context: any) {
417
250
  const callComponent: PatchCallComponent | undefined = context.state.callComponent;
418
251
  let changed = false;
419
-
420
252
  if (callComponent) {
421
- // Use execute's returned diff (same design as Pi's native edit tool)
422
253
  const resultDiff = !context.isError && result.details?.diff;
423
254
  if (typeof resultDiff === "string") {
424
255
  const newPreview = { diff: resultDiff };
@@ -427,13 +258,10 @@ export function setupIO(pi: ExtensionAPI) {
427
258
  changed = true;
428
259
  }
429
260
  }
430
-
431
- // Update error state
432
261
  if (callComponent.settledError !== context.isError) {
433
262
  callComponent.settledError = context.isError;
434
263
  changed = true;
435
264
  }
436
-
437
265
  if (changed) {
438
266
  buildPatchCallComponent(callComponent, context.args, theme, options.expanded);
439
267
  if (context.isError) {
@@ -448,10 +276,6 @@ export function setupIO(pi: ExtensionAPI) {
448
276
  }
449
277
  }
450
278
  }
451
-
452
- // Return empty Container — the Box (callComponent) already holds all content.
453
- // Per pitfall #4: returning the Box would cause ToolExecutionComponent to add
454
- // it twice to the container, producing duplicate rendering.
455
279
  const component = context.lastComponent ?? new Container();
456
280
  component.clear();
457
281
  return component;
package/tsconfig.json CHANGED
@@ -11,6 +11,15 @@
11
11
  "rootDir": ".",
12
12
  "noEmit": true
13
13
  },
14
- "include": ["extensions/**/*.ts", "test/**/*.ts"],
14
+ "include": [
15
+ "index.ts",
16
+ "settings.ts",
17
+ "tools/**/*.ts",
18
+ "hooks/**/*.ts",
19
+ "commands/**/*.ts",
20
+ "providers/**/*.ts",
21
+ "ui/**/*.ts",
22
+ "test/**/*.ts"
23
+ ],
15
24
  "exclude": ["node_modules", "dist"]
16
25
  }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * MCP Status UI — used by /mcp command.
3
+ *
4
+ * Pure presentation: receives callbacks for read/toggle/refresh, knows
5
+ * nothing about the hook layer or config persistence. The caller (in
6
+ * commands/mcp-status.ts) wires up the concrete hook-layer functions.
7
+ */
8
+
9
+ import type { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Spacer, Text, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
11
+
12
+ // ─── Public types ──────────────────────────────────────────────────────────
13
+
14
+ /** Status reported by the read() callback. UI doesn't care where it comes from. */
15
+ export type McpServerState = "connecting" | "connected" | "failed" | "disabled" | "waiting reload";
16
+
17
+ export interface McpServerView {
18
+ name: string;
19
+ url: string;
20
+ source: string;
21
+ state: McpServerState;
22
+ toolCount: number;
23
+ tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>;
24
+ description?: string;
25
+ error?: string;
26
+ }
27
+
28
+ /** Result of a refresh attempt. */
29
+ export interface McpRefreshResult {
30
+ ok: boolean;
31
+ error?: string;
32
+ }
33
+
34
+ /**
35
+ * Callbacks the UI calls back into. The host (commands/<x>-status.ts)
36
+ * injects concrete implementations that bridge to the hook layer.
37
+ */
38
+ export interface McpStatusCallbacks {
39
+ /** Snapshot the current server list. Called on every render tick. */
40
+ read: () => McpServerView[];
41
+ /**
42
+ * Toggle a server's enabled state. Returns true if the config was
43
+ * updated; UI then re-renders. Side effects (connection teardown)
44
+ * are the caller's responsibility.
45
+ */
46
+ toggle: (name: string, enabled: boolean) => Promise<boolean> | boolean;
47
+ /** Force-reconnect a single server. */
48
+ refresh: (name: string) => Promise<McpRefreshResult>;
49
+ }
50
+
51
+ // ─── Internal: border ──────────────────────────────────────────────────────
52
+
53
+ class DynamicBorder implements Component {
54
+ private colorFn: (str: string) => string;
55
+ constructor(theme: PiTheme) { this.colorFn = (str: string) => theme.fg("border", str); }
56
+ invalidate() {}
57
+ render(width: number): string[] { return [this.colorFn("─".repeat(Math.max(1, width)))]; }
58
+ }
59
+
60
+ // ─── Component ─────────────────────────────────────────────────────────────
61
+
62
+ export class McpStatusComponent extends Container {
63
+ private linesComponent: Text;
64
+ private hintComponent: Text;
65
+ private notifyComponent: Text;
66
+ private tui: TUI;
67
+ private theme: PiTheme;
68
+ private done: () => void;
69
+ private callbacks: McpStatusCallbacks;
70
+ private selected = 0;
71
+ private servers: McpServerView[] = [];
72
+ private notifyText = "";
73
+ private notifyTimer: ReturnType<typeof setTimeout> | null = null;
74
+ private refreshing = new Set<string>();
75
+ private autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
76
+
77
+ constructor(tui: TUI, theme: PiTheme, callbacks: McpStatusCallbacks, onDone: () => void) {
78
+ super();
79
+ this.tui = tui; this.theme = theme; this.done = onDone; this.callbacks = callbacks;
80
+ this.addChild(new DynamicBorder(theme));
81
+ this.addChild(new Spacer(1));
82
+ this.linesComponent = new Text("", 1, 0);
83
+ this.addChild(this.linesComponent);
84
+ this.addChild(new Spacer(1));
85
+ this.notifyComponent = new Text("", 1, 0);
86
+ this.addChild(this.notifyComponent);
87
+ this.hintComponent = new Text("", 1, 0);
88
+ this.addChild(this.hintComponent);
89
+ this.addChild(new Spacer(1));
90
+ this.addChild(new DynamicBorder(theme));
91
+ this.updateServers();
92
+ this.renderView();
93
+ this.autoRefreshTimer = setInterval(() => {
94
+ this.updateServers(); this.renderView();
95
+ const allSettled = this.servers.every((s) => s.state !== "connecting");
96
+ if (allSettled && this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; }
97
+ }, 500);
98
+ }
99
+
100
+ private updateServers() {
101
+ this.servers = this.callbacks.read();
102
+ if (this.selected >= this.servers.length) this.selected = Math.max(0, this.servers.length - 1);
103
+ }
104
+
105
+ private renderView() {
106
+ if (this.servers.length === 0) {
107
+ this.linesComponent.setText("No MCP servers configured.");
108
+ this.hintComponent.setText(this.theme.fg("dim", "q close"));
109
+ this.tui.requestRender();
110
+ return;
111
+ }
112
+ const lines: string[] = [`MCP servers (${this.servers.length}):`, ""];
113
+ const namePad = Math.max(...this.servers.map((s) => s.name.length), 12);
114
+ for (let i = 0; i < this.servers.length; i++) {
115
+ const s = this.servers[i];
116
+ const isSelected = i === this.selected;
117
+ const isDisabled = s.state === "disabled";
118
+ const cursor = isSelected ? this.theme.fg("accent", "→ ") : " ";
119
+ let statusIcon: string;
120
+ let statusColor: (s: string) => string;
121
+ if (s.state === "connected") { statusIcon = "🟢"; statusColor = (str: string) => this.theme.fg("accent", str); }
122
+ else if (s.state === "connecting") { statusIcon = "🟡"; statusColor = (str: string) => this.theme.fg("warning", str); }
123
+ else if (s.state === "waiting reload") { statusIcon = "⏳"; statusColor = (str: string) => this.theme.fg("accent", str); }
124
+ else if (s.state === "disabled") { statusIcon = "⚪"; statusColor = (str: string) => this.theme.fg("dim", str); }
125
+ else { statusIcon = "🔴"; statusColor = (str: string) => this.theme.fg("error", str); }
126
+ const name = isDisabled ? this.theme.fg("dim", s.name) : isSelected ? this.theme.fg("accent", s.name) : s.name;
127
+ const namePadding = " ".repeat(Math.max(0, namePad - s.name.length));
128
+ const desc = s.description ? ` — ${s.description.slice(0, 50)}` : "";
129
+ lines.push(`${cursor}${name}${namePadding} ${statusIcon} ${statusColor(s.state)}${desc}`);
130
+ if (isSelected) {
131
+ lines.push(` ${this.theme.fg("dim", s.url)}`);
132
+ if (s.error) lines.push(` ${this.theme.fg("error", `Error: ${s.error}`)}`);
133
+ if (s.tools.length > 0) {
134
+ lines.push(` ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}:`);
135
+ for (const tool of s.tools.slice(0, 6)) {
136
+ const flat = (tool.description ?? "").replace(/\s+/g, " ").trim();
137
+ const td = flat ? (flat.length > 55 ? ` — ${flat.slice(0, 55)}…` : ` — ${flat}`) : "";
138
+ lines.push(` ${tool.name}${td}`);
139
+ }
140
+ if (s.tools.length > 6) lines.push(` ... and ${s.tools.length - 6} more`);
141
+ }
142
+ lines.push("");
143
+ }
144
+ }
145
+ this.linesComponent.setText(lines.join("\n"));
146
+ const s = this.servers[this.selected];
147
+ const toggleHint = s?.state === "disabled" || s?.state === "waiting reload" ? "space enable" : "space disable";
148
+ const hintParts = ["↑↓ navigate", toggleHint, "r refresh", "q close"];
149
+ this.hintComponent.setText(this.theme.fg("dim", hintParts.join(" | ")));
150
+ this.notifyComponent.setText(this.notifyText ? this.theme.fg("warning", this.notifyText) : "");
151
+ this.tui.requestRender();
152
+ }
153
+
154
+ private showNotify(text: string) {
155
+ this.notifyText = text; this.renderView();
156
+ if (this.notifyTimer) clearTimeout(this.notifyTimer);
157
+ this.notifyTimer = setTimeout(() => { this.notifyText = ""; this.renderView(); }, 3000);
158
+ }
159
+
160
+ handleInput(data: string) {
161
+ const kb = getKeybindings();
162
+ if (kb.matches(data, "tui.select.up")) { this.selected = Math.max(0, this.selected - 1); this.renderView(); return; }
163
+ if (kb.matches(data, "tui.select.down")) { this.selected = Math.min(this.servers.length - 1, this.selected + 1); this.renderView(); return; }
164
+ if (data === "q" || data === "\r" || data === "\n" || kb.matches(data, "tui.select.cancel")) {
165
+ if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; }
166
+ if (this.notifyTimer) { clearTimeout(this.notifyTimer); this.notifyTimer = null; }
167
+ this.done();
168
+ return;
169
+ }
170
+ if (this.servers.length === 0) return;
171
+ const s = this.servers[this.selected];
172
+ if (data === " ") {
173
+ const newEnabled = s.state === "disabled";
174
+ void (async () => {
175
+ const ok = await this.callbacks.toggle(s.name, newEnabled);
176
+ this.showNotify(ok ? `${newEnabled ? "Enabled" : "Disabled"} "${s.name}". Use /reload to apply.` : `Failed to toggle "${s.name}".`);
177
+ this.updateServers(); this.renderView();
178
+ })();
179
+ return;
180
+ }
181
+ if (data === "r") {
182
+ if (this.refreshing.has(s.name)) return;
183
+ this.refreshing.add(s.name);
184
+ const targetName = s.name; const targetIndex = this.selected;
185
+ this.showNotify(`Refreshing "${targetName}"...`);
186
+ void (async () => {
187
+ const result = await this.callbacks.refresh(targetName);
188
+ this.refreshing.delete(targetName);
189
+ if (this.selected === targetIndex) {
190
+ this.updateServers(); this.renderView();
191
+ this.showNotify(result.ok ? `Refreshed "${targetName}".` : `Refresh failed: ${result.error}`);
192
+ }
193
+ })();
194
+ return;
195
+ }
196
+ }
197
+
198
+ dispose() {
199
+ if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; }
200
+ if (this.notifyTimer) { clearTimeout(this.notifyTimer); this.notifyTimer = null; }
201
+ }
202
+ }