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.
- package/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -1,53 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* patch — exact string replacement tool.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
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 "./
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (`) +
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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": [
|
|
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
|
}
|
package/ui/mcp-status.ts
ADDED
|
@@ -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
|
+
}
|