@xynogen/pix-pretty 1.0.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/LICENSE +21 -0
- package/README.md +68 -0
- package/package.json +54 -0
- package/src/README.md +66 -0
- package/src/ansi.ts +89 -0
- package/src/config.ts +66 -0
- package/src/diff-render.ts +892 -0
- package/src/diff.ts +68 -0
- package/src/fff.ts +416 -0
- package/src/highlight.ts +118 -0
- package/src/icons.ts +120 -0
- package/src/image.ts +166 -0
- package/src/index.test.ts +33 -0
- package/src/index.ts +1623 -0
- package/src/lang.ts +67 -0
- package/src/paste-chips.test.ts +138 -0
- package/src/paste-chips.ts +160 -0
- package/src/renderers.ts +222 -0
- package/src/thinking.test.ts +223 -0
- package/src/thinking.ts +100 -0
- package/src/tsconfig.json +14 -0
- package/src/types-diff.d.ts +41 -0
- package/src/types-fff.d.ts +80 -0
- package/src/types.ts +275 -0
- package/src/utils.ts +180 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-pretty — Pretty terminal output for pi built-in tools.
|
|
3
|
+
*
|
|
4
|
+
* Entry point: wraps SDK factory tools (read/bash/ls/find/grep + multi_grep),
|
|
5
|
+
* delegates execute() unchanged, and attaches custom renderCall/renderResult.
|
|
6
|
+
*
|
|
7
|
+
* Modules:
|
|
8
|
+
* types.ts shared interfaces/types
|
|
9
|
+
* config.ts theme + thresholds
|
|
10
|
+
* ansi.ts ANSI codes, low-contrast fix
|
|
11
|
+
* utils.ts helpers + renderToolError
|
|
12
|
+
* lang.ts language detection
|
|
13
|
+
* image.ts terminal image protocols
|
|
14
|
+
* icons.ts Nerd Font file-type icons
|
|
15
|
+
* highlight.ts cli-highlight engine + ANSI cache
|
|
16
|
+
* renderers.ts renderFileContent/Bash/Tree/Find/Grep
|
|
17
|
+
* fff.ts Fast File Finder + cursor store + multi-grep fallback
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import type {
|
|
23
|
+
AgentToolUpdateCallback,
|
|
24
|
+
BashToolInput,
|
|
25
|
+
EditToolInput,
|
|
26
|
+
ExtensionContext,
|
|
27
|
+
FindToolInput,
|
|
28
|
+
GrepToolInput,
|
|
29
|
+
LsToolInput,
|
|
30
|
+
ReadToolInput,
|
|
31
|
+
ToolRenderResultOptions,
|
|
32
|
+
WriteToolInput,
|
|
33
|
+
} from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import type { FileItem, GrepResult, SearchResult } from "@ff-labs/fff-node";
|
|
35
|
+
|
|
36
|
+
import { FG_DIM, RST, resolveBaseBackground } from "./ansi.js";
|
|
37
|
+
import {
|
|
38
|
+
getDefaultAgentDir,
|
|
39
|
+
MAX_PREVIEW_LINES,
|
|
40
|
+
MAX_RENDER_LINES,
|
|
41
|
+
setPrettyTheme,
|
|
42
|
+
} from "./config.js";
|
|
43
|
+
import { parseDiff } from "./diff.js";
|
|
44
|
+
import {
|
|
45
|
+
diffThemeCacheKey,
|
|
46
|
+
renderSplit,
|
|
47
|
+
resolveDiffColors,
|
|
48
|
+
summarize,
|
|
49
|
+
} from "./diff-render.js";
|
|
50
|
+
import {
|
|
51
|
+
CursorStore,
|
|
52
|
+
fffDestroy,
|
|
53
|
+
fffEnsureFinder,
|
|
54
|
+
fffFormatGrepText,
|
|
55
|
+
fffState,
|
|
56
|
+
getPiPrettyFffDir,
|
|
57
|
+
runMultiGrepRipgrepFallback,
|
|
58
|
+
} from "./fff.js";
|
|
59
|
+
import { clearHighlightCache, hlBlock } from "./highlight.js";
|
|
60
|
+
import { fileIcon } from "./icons.js";
|
|
61
|
+
import { lang } from "./lang.js";
|
|
62
|
+
import {
|
|
63
|
+
renderBashOutput,
|
|
64
|
+
renderFileContent,
|
|
65
|
+
renderFindResults,
|
|
66
|
+
renderGrepResults,
|
|
67
|
+
renderTree,
|
|
68
|
+
} from "./renderers.js";
|
|
69
|
+
import type {
|
|
70
|
+
BashParams,
|
|
71
|
+
CommandContextLike,
|
|
72
|
+
EditOperation,
|
|
73
|
+
EditParams,
|
|
74
|
+
EditRenderState,
|
|
75
|
+
FindParams,
|
|
76
|
+
FindResultDetails,
|
|
77
|
+
GrepParams,
|
|
78
|
+
GrepRenderState,
|
|
79
|
+
GrepResultDetails,
|
|
80
|
+
LsParams,
|
|
81
|
+
MultiGrepParams,
|
|
82
|
+
MultiGrepRenderState,
|
|
83
|
+
PiPrettyApi,
|
|
84
|
+
PiPrettyDeps,
|
|
85
|
+
PiPrettySdk,
|
|
86
|
+
ReadParams,
|
|
87
|
+
RenderContextLike,
|
|
88
|
+
RenderDetails,
|
|
89
|
+
TextComponentCtor,
|
|
90
|
+
ThemeLike,
|
|
91
|
+
ToolFactory,
|
|
92
|
+
ToolResultLike,
|
|
93
|
+
WriteParams,
|
|
94
|
+
WriteRenderState,
|
|
95
|
+
} from "./types.js";
|
|
96
|
+
import {
|
|
97
|
+
appendNotices,
|
|
98
|
+
buildLiteralAlternationPattern,
|
|
99
|
+
countRipgrepMatches,
|
|
100
|
+
fillToolBackground,
|
|
101
|
+
getConstraintBackedPath,
|
|
102
|
+
getErrorMessage,
|
|
103
|
+
getTextContent,
|
|
104
|
+
humanSize,
|
|
105
|
+
isImageContent,
|
|
106
|
+
isTextContent,
|
|
107
|
+
makeTextResult,
|
|
108
|
+
normalizeLineEndings,
|
|
109
|
+
renderToolError,
|
|
110
|
+
rule,
|
|
111
|
+
setResultDetails,
|
|
112
|
+
shortPath,
|
|
113
|
+
shouldIgnoreCaseForPatterns,
|
|
114
|
+
termW,
|
|
115
|
+
trimToUndefined,
|
|
116
|
+
} from "./utils.js";
|
|
117
|
+
|
|
118
|
+
export default function piPrettyExtension(
|
|
119
|
+
pi: PiPrettyApi,
|
|
120
|
+
deps?: PiPrettyDeps,
|
|
121
|
+
): void {
|
|
122
|
+
let createReadTool: ToolFactory<ReadToolInput> | undefined;
|
|
123
|
+
let createBashTool: ToolFactory<BashToolInput> | undefined;
|
|
124
|
+
let createLsTool: ToolFactory<LsToolInput> | undefined;
|
|
125
|
+
let createFindTool: ToolFactory<FindToolInput> | undefined;
|
|
126
|
+
let createGrepTool: ToolFactory<GrepToolInput> | undefined;
|
|
127
|
+
let createEditTool: ToolFactory<EditToolInput> | undefined;
|
|
128
|
+
let createWriteTool: ToolFactory<WriteToolInput> | undefined;
|
|
129
|
+
let TextComponent: TextComponentCtor;
|
|
130
|
+
|
|
131
|
+
let sdk: PiPrettySdk;
|
|
132
|
+
|
|
133
|
+
const _cursorStore = new CursorStore();
|
|
134
|
+
|
|
135
|
+
if (deps) {
|
|
136
|
+
// Test path: use injected dependencies, reset module state
|
|
137
|
+
sdk = deps.sdk;
|
|
138
|
+
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
139
|
+
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
140
|
+
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
141
|
+
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
142
|
+
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
143
|
+
createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
|
|
144
|
+
createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
|
|
145
|
+
TextComponent = deps.TextComponent;
|
|
146
|
+
fffState.module = deps.fffModule ?? null;
|
|
147
|
+
fffState.finder = null;
|
|
148
|
+
fffState.partialIndex = false;
|
|
149
|
+
fffState.dbDir = null;
|
|
150
|
+
} else {
|
|
151
|
+
try {
|
|
152
|
+
sdk = require("@earendil-works/pi-coding-agent");
|
|
153
|
+
createReadTool = sdk.createReadToolDefinition ?? sdk.createReadTool;
|
|
154
|
+
createBashTool = sdk.createBashToolDefinition ?? sdk.createBashTool;
|
|
155
|
+
createLsTool = sdk.createLsToolDefinition ?? sdk.createLsTool;
|
|
156
|
+
createFindTool = sdk.createFindToolDefinition ?? sdk.createFindTool;
|
|
157
|
+
createGrepTool = sdk.createGrepToolDefinition ?? sdk.createGrepTool;
|
|
158
|
+
createEditTool = sdk.createEditToolDefinition ?? sdk.createEditTool;
|
|
159
|
+
createWriteTool = sdk.createWriteToolDefinition ?? sdk.createWriteTool;
|
|
160
|
+
TextComponent = require("@earendil-works/pi-tui").Text;
|
|
161
|
+
} catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!createReadTool || !TextComponent) return;
|
|
166
|
+
|
|
167
|
+
const cwd = process.cwd();
|
|
168
|
+
const home = process.env.HOME ?? "";
|
|
169
|
+
const sp = (p: string) => shortPath(cwd, home, p);
|
|
170
|
+
const multiGrepRipgrepFallback =
|
|
171
|
+
deps?.multiGrepRipgrepFallback ?? runMultiGrepRipgrepFallback;
|
|
172
|
+
|
|
173
|
+
// Parse PRETTY_DISABLE_TOOLS — comma-separated tool names to skip
|
|
174
|
+
const disabledTools = new Set(
|
|
175
|
+
(process.env.PRETTY_DISABLE_TOOLS ?? "")
|
|
176
|
+
.split(",")
|
|
177
|
+
.map((s) => s.trim().toLowerCase())
|
|
178
|
+
.filter(Boolean),
|
|
179
|
+
);
|
|
180
|
+
function isToolEnabled(name: string): boolean {
|
|
181
|
+
return !disabledTools.has(name.toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===================================================================
|
|
185
|
+
// FFF initialization (optional — graceful fallback to SDK)
|
|
186
|
+
// ===================================================================
|
|
187
|
+
|
|
188
|
+
const getAgentDir = sdk.getAgentDir;
|
|
189
|
+
setPrettyTheme(
|
|
190
|
+
(() => {
|
|
191
|
+
try {
|
|
192
|
+
return getAgentDir?.() ?? getDefaultAgentDir();
|
|
193
|
+
} catch {
|
|
194
|
+
return getDefaultAgentDir();
|
|
195
|
+
}
|
|
196
|
+
})(),
|
|
197
|
+
);
|
|
198
|
+
clearHighlightCache();
|
|
199
|
+
if (!deps) {
|
|
200
|
+
// Only try require() in production — tests inject fffModule via deps
|
|
201
|
+
try {
|
|
202
|
+
fffState.module = require("@ff-labs/fff-node");
|
|
203
|
+
if (getAgentDir) {
|
|
204
|
+
fffState.dbDir = getPiPrettyFffDir(getAgentDir());
|
|
205
|
+
try {
|
|
206
|
+
mkdirSync(fffState.dbDir, { recursive: true });
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
/* FFF not installed — SDK tools will be used */
|
|
211
|
+
}
|
|
212
|
+
} else if (fffState.module && getAgentDir) {
|
|
213
|
+
fffState.dbDir = getPiPrettyFffDir(getAgentDir());
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(fffState.dbDir, { recursive: true });
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
220
|
+
// Try dynamic import if sync require failed (ESM-only package)
|
|
221
|
+
if (!fffState.module) {
|
|
222
|
+
try {
|
|
223
|
+
const imported = await import("@ff-labs/fff-node");
|
|
224
|
+
fffState.module = { FileFinder: imported.FileFinder };
|
|
225
|
+
} catch {}
|
|
226
|
+
}
|
|
227
|
+
if (!fffState.module) return;
|
|
228
|
+
|
|
229
|
+
if (!fffState.dbDir) {
|
|
230
|
+
const agentDir = getAgentDir?.() ?? join(home, ".pi/agent");
|
|
231
|
+
fffState.dbDir = getPiPrettyFffDir(agentDir);
|
|
232
|
+
try {
|
|
233
|
+
mkdirSync(fffState.dbDir, { recursive: true });
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await fffEnsureFinder(ctx.cwd);
|
|
239
|
+
if (fffState.partialIndex) {
|
|
240
|
+
ctx.ui?.notify?.(
|
|
241
|
+
"FFF: scan timed out — using partial index. Run /fff-rescan when ready.",
|
|
242
|
+
"warning",
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
// Confirm indexing via a transient toast instead of a footer status
|
|
246
|
+
// segment — the footer sorts extension statuses by key, and "fff"
|
|
247
|
+
// sorting ahead of other extensions shifted their indicators.
|
|
248
|
+
ctx.ui?.notify?.("FFF indexed", "info");
|
|
249
|
+
}
|
|
250
|
+
} catch (error: unknown) {
|
|
251
|
+
ctx.ui?.notify?.(`FFF init failed: ${getErrorMessage(error)}`, "error");
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
pi.on("session_shutdown", async () => {
|
|
256
|
+
fffDestroy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ===================================================================
|
|
260
|
+
// read — syntax-highlighted file content
|
|
261
|
+
// ===================================================================
|
|
262
|
+
|
|
263
|
+
const origRead = createReadTool(cwd);
|
|
264
|
+
|
|
265
|
+
if (isToolEnabled("read")) {
|
|
266
|
+
pi.registerTool({
|
|
267
|
+
...origRead,
|
|
268
|
+
name: "read",
|
|
269
|
+
|
|
270
|
+
async execute(
|
|
271
|
+
tid: string,
|
|
272
|
+
params: ReadParams,
|
|
273
|
+
sig: AbortSignal | undefined,
|
|
274
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
275
|
+
ctx: ExtensionContext,
|
|
276
|
+
) {
|
|
277
|
+
const result = (await origRead.execute(
|
|
278
|
+
tid,
|
|
279
|
+
params,
|
|
280
|
+
sig,
|
|
281
|
+
upd,
|
|
282
|
+
ctx,
|
|
283
|
+
)) as ToolResultLike;
|
|
284
|
+
|
|
285
|
+
const fp = params.path ?? "";
|
|
286
|
+
const offset = params.offset ?? 1;
|
|
287
|
+
|
|
288
|
+
const imageBlock = result.content?.find(isImageContent);
|
|
289
|
+
if (imageBlock) {
|
|
290
|
+
setResultDetails(result, {
|
|
291
|
+
_type: "readImage",
|
|
292
|
+
filePath: fp,
|
|
293
|
+
data: imageBlock.data,
|
|
294
|
+
mimeType: imageBlock.mimeType ?? "image/png",
|
|
295
|
+
});
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const textContent = getTextContent(result);
|
|
300
|
+
if (textContent && fp) {
|
|
301
|
+
const normalizedContent = normalizeLineEndings(textContent);
|
|
302
|
+
const lineCount = normalizedContent.split("\n").length;
|
|
303
|
+
setResultDetails(result, {
|
|
304
|
+
_type: "readFile",
|
|
305
|
+
filePath: fp,
|
|
306
|
+
content: normalizedContent,
|
|
307
|
+
offset,
|
|
308
|
+
lineCount,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return result;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
316
|
+
resolveBaseBackground(theme);
|
|
317
|
+
const fp = args.path ?? "";
|
|
318
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
319
|
+
const offset = args.offset
|
|
320
|
+
? ` ${theme.fg("muted", `from line ${args.offset}`)}`
|
|
321
|
+
: "";
|
|
322
|
+
const limit = args.limit
|
|
323
|
+
? ` ${theme.fg("muted", `(${args.limit} lines)`)}`
|
|
324
|
+
: "";
|
|
325
|
+
text.setText(
|
|
326
|
+
fillToolBackground(
|
|
327
|
+
`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
return text;
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
renderResult(
|
|
334
|
+
result: ToolResultLike,
|
|
335
|
+
_opt: unknown,
|
|
336
|
+
theme: ThemeLike,
|
|
337
|
+
ctx: RenderContextLike,
|
|
338
|
+
) {
|
|
339
|
+
resolveBaseBackground(theme);
|
|
340
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
341
|
+
|
|
342
|
+
if (ctx.isError) {
|
|
343
|
+
text.setText(
|
|
344
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
345
|
+
);
|
|
346
|
+
return text;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const d = result.details as RenderDetails | undefined;
|
|
350
|
+
|
|
351
|
+
// Image reads keep the original image content so Pi's native TUI renderer
|
|
352
|
+
// can display it exactly once. pi-pretty only renders metadata here;
|
|
353
|
+
// rendering another inline image caused duplicate previews.
|
|
354
|
+
if (d?._type === "readImage") {
|
|
355
|
+
const byteSize = Math.ceil(((d.data as string).length * 3) / 4);
|
|
356
|
+
const sizeStr = humanSize(byteSize);
|
|
357
|
+
const mimeStr = d.mimeType ?? "image";
|
|
358
|
+
|
|
359
|
+
text.setText(
|
|
360
|
+
fillToolBackground(
|
|
361
|
+
` ${fileIcon(d.filePath)}${FG_DIM}${mimeStr} · ${sizeStr}${RST}`,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
return text;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (d?._type === "readFile" && d.content) {
|
|
368
|
+
const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
|
|
369
|
+
if (ctx.state._rk !== key) {
|
|
370
|
+
ctx.state._rk = key;
|
|
371
|
+
const info = `${FG_DIM}${d.lineCount} lines${RST}`;
|
|
372
|
+
ctx.state._rt = fillToolBackground(` ${info}`);
|
|
373
|
+
|
|
374
|
+
const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
|
|
375
|
+
renderFileContent(d.content, d.filePath, d.offset, maxShow)
|
|
376
|
+
.then((rendered: string) => {
|
|
377
|
+
if (ctx.state._rk !== key) return;
|
|
378
|
+
ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`);
|
|
379
|
+
ctx.invalidate();
|
|
380
|
+
})
|
|
381
|
+
.catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
text.setText(
|
|
384
|
+
ctx.state._rt ??
|
|
385
|
+
fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}`),
|
|
386
|
+
);
|
|
387
|
+
return text;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Fallback
|
|
391
|
+
const fallback = result.content?.[0];
|
|
392
|
+
const fallbackText =
|
|
393
|
+
fallback && isTextContent(fallback) ? fallback.text : "read";
|
|
394
|
+
text.setText(
|
|
395
|
+
fillToolBackground(
|
|
396
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
return text;
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ===================================================================
|
|
405
|
+
// bash — colored exit status
|
|
406
|
+
// ===================================================================
|
|
407
|
+
|
|
408
|
+
if (createBashTool) {
|
|
409
|
+
const origBash = createBashTool(cwd);
|
|
410
|
+
|
|
411
|
+
pi.registerTool({
|
|
412
|
+
...origBash,
|
|
413
|
+
name: "bash",
|
|
414
|
+
|
|
415
|
+
async execute(
|
|
416
|
+
tid: string,
|
|
417
|
+
params: BashParams,
|
|
418
|
+
sig: AbortSignal | undefined,
|
|
419
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
420
|
+
ctx: ExtensionContext,
|
|
421
|
+
) {
|
|
422
|
+
const result = (await origBash.execute(
|
|
423
|
+
tid,
|
|
424
|
+
params,
|
|
425
|
+
sig,
|
|
426
|
+
upd,
|
|
427
|
+
ctx,
|
|
428
|
+
)) as ToolResultLike;
|
|
429
|
+
const textContent = getTextContent(result);
|
|
430
|
+
|
|
431
|
+
let exitCode: number | null = 0;
|
|
432
|
+
if (textContent) {
|
|
433
|
+
const exitMatch = textContent.match(
|
|
434
|
+
/(?:exit code|exited with|exit status)[:\s]*(\d+)/i,
|
|
435
|
+
);
|
|
436
|
+
if (exitMatch) exitCode = Number(exitMatch[1]);
|
|
437
|
+
if (
|
|
438
|
+
textContent.includes("command not found") ||
|
|
439
|
+
textContent.includes("No such file")
|
|
440
|
+
) {
|
|
441
|
+
exitCode = 1;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setResultDetails(result, {
|
|
446
|
+
_type: "bashResult",
|
|
447
|
+
text: textContent ?? "",
|
|
448
|
+
exitCode,
|
|
449
|
+
command: params.command ?? "",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return result;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
456
|
+
resolveBaseBackground(theme);
|
|
457
|
+
const cmd = args.command ?? "";
|
|
458
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
459
|
+
const timeout = args.timeout
|
|
460
|
+
? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}`
|
|
461
|
+
: "";
|
|
462
|
+
const displayCmd =
|
|
463
|
+
ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
|
|
464
|
+
text.setText(
|
|
465
|
+
fillToolBackground(
|
|
466
|
+
`${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
return text;
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
renderResult(
|
|
473
|
+
result: ToolResultLike,
|
|
474
|
+
_opt: unknown,
|
|
475
|
+
theme: ThemeLike,
|
|
476
|
+
ctx: RenderContextLike,
|
|
477
|
+
) {
|
|
478
|
+
resolveBaseBackground(theme);
|
|
479
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
480
|
+
|
|
481
|
+
if (ctx.isError) {
|
|
482
|
+
text.setText(
|
|
483
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
484
|
+
);
|
|
485
|
+
return text;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const d = result.details as RenderDetails | undefined;
|
|
489
|
+
if (d?._type === "bashResult") {
|
|
490
|
+
const { summary } = renderBashOutput(d.text, d.exitCode);
|
|
491
|
+
const lines = d.text.split("\n");
|
|
492
|
+
const lineCount = lines.length;
|
|
493
|
+
const lineInfo =
|
|
494
|
+
lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
|
|
495
|
+
const header = ` ${summary}${lineInfo}`;
|
|
496
|
+
|
|
497
|
+
if (d.text.trim()) {
|
|
498
|
+
const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
499
|
+
const show = lines.slice(0, maxShow);
|
|
500
|
+
const tw = termW();
|
|
501
|
+
const out: string[] = [header, rule(tw)];
|
|
502
|
+
for (const line of show) {
|
|
503
|
+
out.push(` ${line}`);
|
|
504
|
+
}
|
|
505
|
+
out.push(rule(tw));
|
|
506
|
+
if (lineCount > maxShow) {
|
|
507
|
+
out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
|
|
508
|
+
}
|
|
509
|
+
text.setText(fillToolBackground(out.join("\n")));
|
|
510
|
+
} else {
|
|
511
|
+
text.setText(fillToolBackground(header));
|
|
512
|
+
}
|
|
513
|
+
return text;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const fallback = result.content?.[0];
|
|
517
|
+
const fallbackText =
|
|
518
|
+
fallback && isTextContent(fallback) ? fallback.text : "done";
|
|
519
|
+
text.setText(
|
|
520
|
+
fillToolBackground(
|
|
521
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
return text;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ===================================================================
|
|
530
|
+
// ls — tree view with icons
|
|
531
|
+
// ===================================================================
|
|
532
|
+
|
|
533
|
+
if (createLsTool) {
|
|
534
|
+
const origLs = createLsTool(cwd);
|
|
535
|
+
|
|
536
|
+
pi.registerTool({
|
|
537
|
+
...origLs,
|
|
538
|
+
name: "ls",
|
|
539
|
+
|
|
540
|
+
async execute(
|
|
541
|
+
tid: string,
|
|
542
|
+
params: LsParams,
|
|
543
|
+
sig: AbortSignal | undefined,
|
|
544
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
545
|
+
ctx: ExtensionContext,
|
|
546
|
+
) {
|
|
547
|
+
const result = (await origLs.execute(
|
|
548
|
+
tid,
|
|
549
|
+
params,
|
|
550
|
+
sig,
|
|
551
|
+
upd,
|
|
552
|
+
ctx,
|
|
553
|
+
)) as ToolResultLike;
|
|
554
|
+
const textContent = getTextContent(result);
|
|
555
|
+
const fp = params.path ?? cwd;
|
|
556
|
+
const entryCount = textContent
|
|
557
|
+
? textContent.trim().split("\n").filter(Boolean).length
|
|
558
|
+
: 0;
|
|
559
|
+
|
|
560
|
+
setResultDetails(result, {
|
|
561
|
+
_type: "lsResult",
|
|
562
|
+
text: textContent ?? "",
|
|
563
|
+
path: fp,
|
|
564
|
+
entryCount,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
return result;
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
571
|
+
resolveBaseBackground(theme);
|
|
572
|
+
const fp = args.path ?? ".";
|
|
573
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
574
|
+
text.setText(
|
|
575
|
+
fillToolBackground(
|
|
576
|
+
`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`,
|
|
577
|
+
),
|
|
578
|
+
);
|
|
579
|
+
return text;
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
renderResult(
|
|
583
|
+
result: ToolResultLike,
|
|
584
|
+
_opt: unknown,
|
|
585
|
+
theme: ThemeLike,
|
|
586
|
+
ctx: RenderContextLike,
|
|
587
|
+
) {
|
|
588
|
+
resolveBaseBackground(theme);
|
|
589
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
590
|
+
|
|
591
|
+
if (ctx.isError) {
|
|
592
|
+
text.setText(
|
|
593
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
594
|
+
);
|
|
595
|
+
return text;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const d = result.details as RenderDetails | undefined;
|
|
599
|
+
if (d?._type === "lsResult" && d.text) {
|
|
600
|
+
const tree = renderTree(d.text, d.path);
|
|
601
|
+
const info = `${FG_DIM}${d.entryCount} entries${RST}`;
|
|
602
|
+
text.setText(fillToolBackground(` ${info}\n${tree}`));
|
|
603
|
+
return text;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const fallback = result.content?.[0];
|
|
607
|
+
const fallbackText =
|
|
608
|
+
fallback && isTextContent(fallback) ? fallback.text : "listed";
|
|
609
|
+
text.setText(
|
|
610
|
+
fillToolBackground(
|
|
611
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
612
|
+
),
|
|
613
|
+
);
|
|
614
|
+
return text;
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ===================================================================
|
|
620
|
+
// find — grouped file list with icons
|
|
621
|
+
// ===================================================================
|
|
622
|
+
|
|
623
|
+
if (createFindTool) {
|
|
624
|
+
const origFind = createFindTool(cwd);
|
|
625
|
+
|
|
626
|
+
pi.registerTool({
|
|
627
|
+
...origFind,
|
|
628
|
+
name: "find",
|
|
629
|
+
|
|
630
|
+
async execute(
|
|
631
|
+
tid: string,
|
|
632
|
+
params: FindParams,
|
|
633
|
+
sig: AbortSignal | undefined,
|
|
634
|
+
upd: unknown,
|
|
635
|
+
ctx: ExtensionContext,
|
|
636
|
+
) {
|
|
637
|
+
// Try FFF first (frecency-ranked, SIMD-accelerated)
|
|
638
|
+
if (fffState.finder && !fffState.finder.isDestroyed) {
|
|
639
|
+
try {
|
|
640
|
+
const effectiveLimit = Math.max(1, params.limit ?? 200);
|
|
641
|
+
let query = params.pattern;
|
|
642
|
+
if (params.path) query = `${params.path} ${query}`;
|
|
643
|
+
|
|
644
|
+
const searchResult = fffState.finder.fileSearch(query, {
|
|
645
|
+
pageSize: effectiveLimit,
|
|
646
|
+
});
|
|
647
|
+
if (searchResult.ok) {
|
|
648
|
+
const search: SearchResult = searchResult.value;
|
|
649
|
+
const items: FileItem[] = search.items.slice(0, effectiveLimit);
|
|
650
|
+
const notices: string[] = [];
|
|
651
|
+
if (fffState.partialIndex)
|
|
652
|
+
notices.push("Warning: partial file index");
|
|
653
|
+
if (items.length >= effectiveLimit)
|
|
654
|
+
notices.push(`${effectiveLimit} limit reached`);
|
|
655
|
+
if (search.totalMatched > items.length)
|
|
656
|
+
notices.push(`${search.totalMatched} total matches`);
|
|
657
|
+
|
|
658
|
+
const textContent = appendNotices(
|
|
659
|
+
items.map((item) => item.relativePath).join("\n"),
|
|
660
|
+
notices,
|
|
661
|
+
);
|
|
662
|
+
return makeTextResult<FindResultDetails>(textContent, {
|
|
663
|
+
_type: "findResult",
|
|
664
|
+
text: textContent,
|
|
665
|
+
pattern: params.pattern,
|
|
666
|
+
matchCount: items.length,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
/* fall through to SDK */
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// SDK fallback
|
|
675
|
+
const result = await origFind.execute(
|
|
676
|
+
tid,
|
|
677
|
+
params,
|
|
678
|
+
sig,
|
|
679
|
+
upd as never,
|
|
680
|
+
ctx,
|
|
681
|
+
);
|
|
682
|
+
const textContent = getTextContent(result);
|
|
683
|
+
const matchCount = textContent
|
|
684
|
+
? textContent.trim().split("\n").filter(Boolean).length
|
|
685
|
+
: 0;
|
|
686
|
+
|
|
687
|
+
setResultDetails<FindResultDetails>(result, {
|
|
688
|
+
_type: "findResult",
|
|
689
|
+
text: textContent,
|
|
690
|
+
pattern: params.pattern,
|
|
691
|
+
matchCount,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
return result;
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
698
|
+
resolveBaseBackground(theme);
|
|
699
|
+
const pattern = args.pattern ?? "";
|
|
700
|
+
const path = args.path
|
|
701
|
+
? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
|
|
702
|
+
: "";
|
|
703
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
704
|
+
text.setText(
|
|
705
|
+
fillToolBackground(
|
|
706
|
+
`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`,
|
|
707
|
+
),
|
|
708
|
+
);
|
|
709
|
+
return text;
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
renderResult(
|
|
713
|
+
result: ToolResultLike<FindResultDetails>,
|
|
714
|
+
_opt: ToolRenderResultOptions,
|
|
715
|
+
theme: ThemeLike,
|
|
716
|
+
ctx: RenderContextLike,
|
|
717
|
+
) {
|
|
718
|
+
resolveBaseBackground(theme);
|
|
719
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
720
|
+
|
|
721
|
+
if (ctx.isError) {
|
|
722
|
+
text.setText(
|
|
723
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
724
|
+
);
|
|
725
|
+
return text;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const d = result.details;
|
|
729
|
+
if (d?._type === "findResult" && d.text) {
|
|
730
|
+
const rendered = renderFindResults(d.text);
|
|
731
|
+
const info = `${FG_DIM}${d.matchCount} files${RST}`;
|
|
732
|
+
text.setText(fillToolBackground(` ${info}\n${rendered}`));
|
|
733
|
+
return text;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const fallback = result.content?.[0];
|
|
737
|
+
const fallbackText =
|
|
738
|
+
fallback && isTextContent(fallback) ? fallback.text : "found";
|
|
739
|
+
text.setText(
|
|
740
|
+
fillToolBackground(
|
|
741
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
742
|
+
),
|
|
743
|
+
);
|
|
744
|
+
return text;
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ===================================================================
|
|
750
|
+
// grep — highlighted matches with line numbers
|
|
751
|
+
// ===================================================================
|
|
752
|
+
|
|
753
|
+
if (createGrepTool) {
|
|
754
|
+
const origGrep = createGrepTool(cwd);
|
|
755
|
+
|
|
756
|
+
pi.registerTool({
|
|
757
|
+
...origGrep,
|
|
758
|
+
name: "grep",
|
|
759
|
+
|
|
760
|
+
async execute(
|
|
761
|
+
tid: string,
|
|
762
|
+
params: GrepParams,
|
|
763
|
+
sig: AbortSignal | undefined,
|
|
764
|
+
upd: unknown,
|
|
765
|
+
ctx: ExtensionContext,
|
|
766
|
+
) {
|
|
767
|
+
// Try FFF first (SIMD-accelerated, frecency-ranked).
|
|
768
|
+
// FFF 0.5.2 can abort the process when path/glob constraints meet
|
|
769
|
+
// Unicode filenames, so constrained searches use the SDK fallback.
|
|
770
|
+
if (
|
|
771
|
+
fffState.finder &&
|
|
772
|
+
!fffState.finder.isDestroyed &&
|
|
773
|
+
!params.path &&
|
|
774
|
+
!params.glob
|
|
775
|
+
) {
|
|
776
|
+
try {
|
|
777
|
+
const effectiveLimit = Math.max(1, params.limit ?? 100);
|
|
778
|
+
const query = params.pattern;
|
|
779
|
+
|
|
780
|
+
const grepResult = fffState.finder.grep(query, {
|
|
781
|
+
mode: params.literal ? "plain" : "regex",
|
|
782
|
+
smartCase: !params.ignoreCase,
|
|
783
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
784
|
+
cursor: null,
|
|
785
|
+
beforeContext: params.context ?? 0,
|
|
786
|
+
afterContext: params.context ?? 0,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
if (grepResult.ok) {
|
|
790
|
+
const grep: GrepResult = grepResult.value;
|
|
791
|
+
const notices: string[] = [];
|
|
792
|
+
if (fffState.partialIndex)
|
|
793
|
+
notices.push("Warning: partial file index");
|
|
794
|
+
if (grep.items.length >= effectiveLimit)
|
|
795
|
+
notices.push(`${effectiveLimit} limit reached`);
|
|
796
|
+
if (grep.regexFallbackError)
|
|
797
|
+
notices.push(
|
|
798
|
+
`Regex failed: ${grep.regexFallbackError}, used literal match`,
|
|
799
|
+
);
|
|
800
|
+
if (grep.nextCursor) {
|
|
801
|
+
const cursorId = _cursorStore.store(grep.nextCursor);
|
|
802
|
+
notices.push(
|
|
803
|
+
`More results available. Use cursor="${cursorId}" to continue`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const textContent = appendNotices(
|
|
808
|
+
fffFormatGrepText(grep.items, effectiveLimit),
|
|
809
|
+
notices,
|
|
810
|
+
);
|
|
811
|
+
return makeTextResult<GrepResultDetails>(textContent, {
|
|
812
|
+
_type: "grepResult",
|
|
813
|
+
text: textContent,
|
|
814
|
+
pattern: params.pattern,
|
|
815
|
+
matchCount: Math.min(grep.items.length, effectiveLimit),
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
} catch {
|
|
819
|
+
/* fall through to SDK */
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// SDK fallback
|
|
824
|
+
const result = await origGrep.execute(
|
|
825
|
+
tid,
|
|
826
|
+
params,
|
|
827
|
+
sig,
|
|
828
|
+
upd as never,
|
|
829
|
+
ctx,
|
|
830
|
+
);
|
|
831
|
+
const textContent = normalizeLineEndings(getTextContent(result));
|
|
832
|
+
if (result.content) {
|
|
833
|
+
for (const content of result.content) {
|
|
834
|
+
if (isTextContent(content))
|
|
835
|
+
content.text = normalizeLineEndings(content.text || "");
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const matchCount = textContent ? countRipgrepMatches(textContent) : 0;
|
|
839
|
+
|
|
840
|
+
setResultDetails<GrepResultDetails>(result, {
|
|
841
|
+
_type: "grepResult",
|
|
842
|
+
text: textContent,
|
|
843
|
+
pattern: params.pattern,
|
|
844
|
+
matchCount,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
return result;
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
851
|
+
resolveBaseBackground(theme);
|
|
852
|
+
const pattern = args.pattern ?? "";
|
|
853
|
+
const path = args.path
|
|
854
|
+
? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
|
|
855
|
+
: "";
|
|
856
|
+
const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
|
|
857
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
858
|
+
text.setText(
|
|
859
|
+
fillToolBackground(
|
|
860
|
+
`${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
return text;
|
|
864
|
+
},
|
|
865
|
+
|
|
866
|
+
renderResult(
|
|
867
|
+
result: ToolResultLike<GrepResultDetails>,
|
|
868
|
+
_opt: ToolRenderResultOptions,
|
|
869
|
+
theme: ThemeLike,
|
|
870
|
+
ctx: RenderContextLike<GrepRenderState>,
|
|
871
|
+
) {
|
|
872
|
+
resolveBaseBackground(theme);
|
|
873
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
874
|
+
|
|
875
|
+
if (ctx.isError) {
|
|
876
|
+
text.setText(
|
|
877
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
878
|
+
);
|
|
879
|
+
return text;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const d = result.details;
|
|
883
|
+
if (d?._type === "grepResult" && d.text) {
|
|
884
|
+
const key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
|
|
885
|
+
if (ctx.state._gk !== key) {
|
|
886
|
+
ctx.state._gk = key;
|
|
887
|
+
const info = `${FG_DIM}${d.matchCount} matches${RST}`;
|
|
888
|
+
ctx.state._gt = fillToolBackground(` ${info}`);
|
|
889
|
+
|
|
890
|
+
renderGrepResults(d.text, d.pattern)
|
|
891
|
+
.then((rendered: string) => {
|
|
892
|
+
if (ctx.state._gk !== key) return;
|
|
893
|
+
ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`);
|
|
894
|
+
ctx.invalidate();
|
|
895
|
+
})
|
|
896
|
+
.catch(() => {});
|
|
897
|
+
}
|
|
898
|
+
text.setText(
|
|
899
|
+
ctx.state._gt ??
|
|
900
|
+
fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}`),
|
|
901
|
+
);
|
|
902
|
+
return text;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const fallback = result.content?.[0];
|
|
906
|
+
const fallbackText =
|
|
907
|
+
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
908
|
+
text.setText(
|
|
909
|
+
fillToolBackground(
|
|
910
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
911
|
+
),
|
|
912
|
+
);
|
|
913
|
+
return text;
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ===================================================================
|
|
919
|
+
// multi_grep — OR-logic multi-pattern search (FFF when available,
|
|
920
|
+
// SDK grep fallback otherwise)
|
|
921
|
+
// ===================================================================
|
|
922
|
+
|
|
923
|
+
if ((fffState.module || createGrepTool)) {
|
|
924
|
+
const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
|
|
925
|
+
|
|
926
|
+
pi.registerTool({
|
|
927
|
+
name: "multi_grep",
|
|
928
|
+
label: "multi_grep",
|
|
929
|
+
description: [
|
|
930
|
+
"Search file contents for lines matching ANY of multiple patterns (OR logic).",
|
|
931
|
+
"Uses SIMD-accelerated Aho-Corasick multi-pattern matching when FFF is available.",
|
|
932
|
+
"Falls back to ripgrep while preserving literal OR semantics and file constraints when needed.",
|
|
933
|
+
"Patterns are literal text — never escape special characters.",
|
|
934
|
+
"Use path to scope a directory/file and constraints for file filtering ('*.rs', 'src/', '!test/').",
|
|
935
|
+
].join(" "),
|
|
936
|
+
promptSnippet:
|
|
937
|
+
"Multi-pattern OR search across file contents (FFF-accelerated with grep fallback)",
|
|
938
|
+
promptGuidelines: [
|
|
939
|
+
"Use multi_grep when you need to find multiple identifiers at once (OR logic).",
|
|
940
|
+
"Include all naming conventions: snake_case, PascalCase, camelCase variants.",
|
|
941
|
+
"Patterns are literal text. Never escape special characters.",
|
|
942
|
+
"Use path to scope a directory or file when you need fresh on-disk results.",
|
|
943
|
+
"Use the constraints parameter for additional file filtering, not inside patterns.",
|
|
944
|
+
],
|
|
945
|
+
|
|
946
|
+
parameters: {
|
|
947
|
+
type: "object",
|
|
948
|
+
properties: {
|
|
949
|
+
patterns: {
|
|
950
|
+
type: "array",
|
|
951
|
+
items: { type: "string" },
|
|
952
|
+
description:
|
|
953
|
+
"Patterns to search for (OR logic — matches lines containing ANY pattern).",
|
|
954
|
+
},
|
|
955
|
+
path: {
|
|
956
|
+
type: "string",
|
|
957
|
+
description:
|
|
958
|
+
"Directory or file path to search (default: current directory)",
|
|
959
|
+
},
|
|
960
|
+
constraints: {
|
|
961
|
+
type: "string",
|
|
962
|
+
description:
|
|
963
|
+
"File constraints, e.g. '*.{ts,tsx} !test/' to filter files.",
|
|
964
|
+
},
|
|
965
|
+
context: {
|
|
966
|
+
type: "number",
|
|
967
|
+
description:
|
|
968
|
+
"Number of context lines before and after each match (default: 0)",
|
|
969
|
+
},
|
|
970
|
+
limit: {
|
|
971
|
+
type: "number",
|
|
972
|
+
description: "Maximum number of matches to return (default: 100)",
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
required: ["patterns"],
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
async execute(
|
|
979
|
+
tid: string,
|
|
980
|
+
params: MultiGrepParams,
|
|
981
|
+
sig: AbortSignal | undefined,
|
|
982
|
+
upd: unknown,
|
|
983
|
+
ctx: ExtensionContext,
|
|
984
|
+
) {
|
|
985
|
+
if (sig?.aborted) return makeTextResult("Aborted", {});
|
|
986
|
+
|
|
987
|
+
if (!params.patterns || params.patterns.length === 0) {
|
|
988
|
+
return makeTextResult(
|
|
989
|
+
"Error: patterns array must have at least 1 element",
|
|
990
|
+
{ error: "empty patterns" },
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const effectiveLimit = Math.max(1, params.limit ?? 100);
|
|
995
|
+
const pattern = buildLiteralAlternationPattern(params.patterns);
|
|
996
|
+
const requestedPath = trimToUndefined(params.path);
|
|
997
|
+
const requestedConstraints = trimToUndefined(params.constraints);
|
|
998
|
+
const effectivePath =
|
|
999
|
+
requestedPath ?? getConstraintBackedPath(requestedConstraints);
|
|
1000
|
+
const hasNativeConstraints = Boolean(
|
|
1001
|
+
requestedPath || requestedConstraints,
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
if (
|
|
1005
|
+
fffState.finder &&
|
|
1006
|
+
!fffState.finder.isDestroyed &&
|
|
1007
|
+
!hasNativeConstraints
|
|
1008
|
+
) {
|
|
1009
|
+
try {
|
|
1010
|
+
const grepResult = fffState.finder.multiGrep({
|
|
1011
|
+
patterns: params.patterns,
|
|
1012
|
+
maxMatchesPerFile: Math.min(effectiveLimit, 50),
|
|
1013
|
+
smartCase: true,
|
|
1014
|
+
cursor: null,
|
|
1015
|
+
beforeContext: params.context ?? 0,
|
|
1016
|
+
afterContext: params.context ?? 0,
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
if (!grepResult.ok) {
|
|
1020
|
+
return makeTextResult(`multi_grep error: ${grepResult.error}`, {
|
|
1021
|
+
error: grepResult.error,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const grep: GrepResult = grepResult.value;
|
|
1026
|
+
const notices: string[] = [];
|
|
1027
|
+
if (fffState.partialIndex)
|
|
1028
|
+
notices.push("Warning: partial file index");
|
|
1029
|
+
if (grep.items.length >= effectiveLimit)
|
|
1030
|
+
notices.push(`${effectiveLimit} limit reached`);
|
|
1031
|
+
if (grep.nextCursor) {
|
|
1032
|
+
const cursorId = _cursorStore.store(grep.nextCursor);
|
|
1033
|
+
notices.push(`More results: cursor="${cursorId}"`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const textContent = appendNotices(
|
|
1037
|
+
fffFormatGrepText(grep.items, effectiveLimit),
|
|
1038
|
+
notices,
|
|
1039
|
+
);
|
|
1040
|
+
return makeTextResult<GrepResultDetails>(textContent, {
|
|
1041
|
+
_type: "grepResult",
|
|
1042
|
+
text: textContent,
|
|
1043
|
+
pattern,
|
|
1044
|
+
matchCount: Math.min(grep.items.length, effectiveLimit),
|
|
1045
|
+
});
|
|
1046
|
+
} catch {
|
|
1047
|
+
/* fall through to SDK */
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (requestedConstraints || !multiGrepFallback) {
|
|
1052
|
+
try {
|
|
1053
|
+
const pathBackedConstraint = Boolean(
|
|
1054
|
+
requestedConstraints &&
|
|
1055
|
+
!requestedPath &&
|
|
1056
|
+
requestedConstraints === effectivePath,
|
|
1057
|
+
);
|
|
1058
|
+
const constraintsForRipgrep = pathBackedConstraint
|
|
1059
|
+
? undefined
|
|
1060
|
+
: requestedConstraints;
|
|
1061
|
+
const notices: string[] = [];
|
|
1062
|
+
|
|
1063
|
+
if (!fffState.finder || fffState.finder.isDestroyed)
|
|
1064
|
+
notices.push("FFF unavailable, used ripgrep fallback");
|
|
1065
|
+
else if (hasNativeConstraints)
|
|
1066
|
+
notices.push("Used ripgrep fallback for constrained search");
|
|
1067
|
+
else notices.push("Used ripgrep fallback");
|
|
1068
|
+
|
|
1069
|
+
const rgResult = await multiGrepRipgrepFallback({
|
|
1070
|
+
cwd,
|
|
1071
|
+
patterns: params.patterns,
|
|
1072
|
+
path: effectivePath,
|
|
1073
|
+
constraints: constraintsForRipgrep,
|
|
1074
|
+
ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
|
|
1075
|
+
context: params.context,
|
|
1076
|
+
limit: effectiveLimit,
|
|
1077
|
+
signal: sig,
|
|
1078
|
+
});
|
|
1079
|
+
const textContent =
|
|
1080
|
+
normalizeLineEndings(rgResult.text) || "No matches found";
|
|
1081
|
+
if (rgResult.limitReached)
|
|
1082
|
+
notices.push(`${effectiveLimit} limit reached`);
|
|
1083
|
+
const finalText = appendNotices(textContent, notices);
|
|
1084
|
+
|
|
1085
|
+
return makeTextResult<GrepResultDetails>(finalText, {
|
|
1086
|
+
_type: "grepResult",
|
|
1087
|
+
text: finalText,
|
|
1088
|
+
pattern,
|
|
1089
|
+
matchCount: rgResult.matchCount,
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error: unknown) {
|
|
1092
|
+
const message = getErrorMessage(error);
|
|
1093
|
+
return makeTextResult(`multi_grep error: ${message}`, {
|
|
1094
|
+
error: message,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
try {
|
|
1100
|
+
const notices: string[] = [];
|
|
1101
|
+
if (!fffState.finder || fffState.finder.isDestroyed)
|
|
1102
|
+
notices.push("FFF unavailable, used SDK grep fallback");
|
|
1103
|
+
|
|
1104
|
+
const result = await multiGrepFallback.execute(
|
|
1105
|
+
tid,
|
|
1106
|
+
{
|
|
1107
|
+
pattern,
|
|
1108
|
+
path: effectivePath,
|
|
1109
|
+
ignoreCase: shouldIgnoreCaseForPatterns(params.patterns),
|
|
1110
|
+
context: params.context,
|
|
1111
|
+
limit: params.limit,
|
|
1112
|
+
},
|
|
1113
|
+
sig,
|
|
1114
|
+
upd as never,
|
|
1115
|
+
ctx,
|
|
1116
|
+
);
|
|
1117
|
+
const textContent =
|
|
1118
|
+
normalizeLineEndings(getTextContent(result)) || "No matches found";
|
|
1119
|
+
const finalText = appendNotices(textContent, notices);
|
|
1120
|
+
|
|
1121
|
+
return makeTextResult<GrepResultDetails>(finalText, {
|
|
1122
|
+
_type: "grepResult",
|
|
1123
|
+
text: finalText,
|
|
1124
|
+
pattern,
|
|
1125
|
+
matchCount: textContent ? countRipgrepMatches(textContent) : 0,
|
|
1126
|
+
});
|
|
1127
|
+
} catch (error: unknown) {
|
|
1128
|
+
const message = getErrorMessage(error);
|
|
1129
|
+
return makeTextResult(`multi_grep error: ${message}`, {
|
|
1130
|
+
error: message,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
renderCall(
|
|
1136
|
+
args: MultiGrepParams,
|
|
1137
|
+
theme: ThemeLike,
|
|
1138
|
+
ctx: RenderContextLike,
|
|
1139
|
+
) {
|
|
1140
|
+
resolveBaseBackground(theme);
|
|
1141
|
+
const patterns = args.patterns ?? [];
|
|
1142
|
+
const path = args.path
|
|
1143
|
+
? ` ${theme.fg("muted", `in ${sp(args.path)}`)}`
|
|
1144
|
+
: "";
|
|
1145
|
+
const constraints = args.constraints;
|
|
1146
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1147
|
+
let content =
|
|
1148
|
+
theme.fg("toolTitle", theme.bold("multi_grep")) +
|
|
1149
|
+
" " +
|
|
1150
|
+
theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
|
|
1151
|
+
content += path;
|
|
1152
|
+
if (constraints) content += theme.fg("muted", ` (${constraints})`);
|
|
1153
|
+
text.setText(fillToolBackground(content));
|
|
1154
|
+
return text;
|
|
1155
|
+
},
|
|
1156
|
+
|
|
1157
|
+
renderResult(
|
|
1158
|
+
result: ToolResultLike<GrepResultDetails | { error?: string }>,
|
|
1159
|
+
_opt: ToolRenderResultOptions,
|
|
1160
|
+
theme: ThemeLike,
|
|
1161
|
+
ctx: RenderContextLike<MultiGrepRenderState>,
|
|
1162
|
+
) {
|
|
1163
|
+
resolveBaseBackground(theme);
|
|
1164
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1165
|
+
|
|
1166
|
+
if (ctx.isError) {
|
|
1167
|
+
text.setText(
|
|
1168
|
+
`\n${theme.fg("error", getTextContent(result) || "Error")}`,
|
|
1169
|
+
);
|
|
1170
|
+
return text;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const d = result.details;
|
|
1174
|
+
if (d && "_type" in d && d._type === "grepResult" && d.text) {
|
|
1175
|
+
const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
|
|
1176
|
+
if (ctx.state._mgk !== key) {
|
|
1177
|
+
ctx.state._mgk = key;
|
|
1178
|
+
const info = `${FG_DIM}${d.matchCount} matches${RST}`;
|
|
1179
|
+
ctx.state._mgt = ` ${info}`;
|
|
1180
|
+
|
|
1181
|
+
renderGrepResults(d.text, d.pattern)
|
|
1182
|
+
.then((rendered: string) => {
|
|
1183
|
+
if (ctx.state._mgk !== key) return;
|
|
1184
|
+
ctx.state._mgt = ` ${info}\n${rendered}`;
|
|
1185
|
+
ctx.invalidate();
|
|
1186
|
+
})
|
|
1187
|
+
.catch(() => {});
|
|
1188
|
+
}
|
|
1189
|
+
text.setText(
|
|
1190
|
+
ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`,
|
|
1191
|
+
);
|
|
1192
|
+
return text;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const fallback = result.content?.[0];
|
|
1196
|
+
const fallbackText =
|
|
1197
|
+
fallback && isTextContent(fallback) ? fallback.text : "searched";
|
|
1198
|
+
text.setText(
|
|
1199
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
1200
|
+
);
|
|
1201
|
+
return text;
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ===================================================================
|
|
1207
|
+
// edit — split/unified/word-level diff preview
|
|
1208
|
+
// ===================================================================
|
|
1209
|
+
|
|
1210
|
+
function getEditOperations(input: EditParams): EditOperation[] {
|
|
1211
|
+
if (Array.isArray(input?.edits)) {
|
|
1212
|
+
return input.edits
|
|
1213
|
+
.map((e) => ({
|
|
1214
|
+
oldText:
|
|
1215
|
+
typeof e?.oldText === "string"
|
|
1216
|
+
? e.oldText
|
|
1217
|
+
: typeof e?.old_text === "string"
|
|
1218
|
+
? e.old_text
|
|
1219
|
+
: "",
|
|
1220
|
+
newText:
|
|
1221
|
+
typeof e?.newText === "string"
|
|
1222
|
+
? e.newText
|
|
1223
|
+
: typeof e?.new_text === "string"
|
|
1224
|
+
? e.new_text
|
|
1225
|
+
: "",
|
|
1226
|
+
}))
|
|
1227
|
+
.filter((e) => e.oldText && e.oldText !== e.newText);
|
|
1228
|
+
}
|
|
1229
|
+
const oldText =
|
|
1230
|
+
typeof input?.oldText === "string"
|
|
1231
|
+
? input.oldText
|
|
1232
|
+
: typeof input?.old_text === "string"
|
|
1233
|
+
? input.old_text
|
|
1234
|
+
: "";
|
|
1235
|
+
const newText =
|
|
1236
|
+
typeof input?.newText === "string"
|
|
1237
|
+
? input.newText
|
|
1238
|
+
: typeof input?.new_text === "string"
|
|
1239
|
+
? input.new_text
|
|
1240
|
+
: "";
|
|
1241
|
+
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function summarizeEditOperations(operations: EditOperation[]) {
|
|
1245
|
+
const diffs = operations.map((e) => parseDiff(e.oldText, e.newText));
|
|
1246
|
+
const totalAdded = diffs.reduce((sum, d) => sum + d.added, 0);
|
|
1247
|
+
const totalRemoved = diffs.reduce((sum, d) => sum + d.removed, 0);
|
|
1248
|
+
return {
|
|
1249
|
+
diffs,
|
|
1250
|
+
totalAdded,
|
|
1251
|
+
totalRemoved,
|
|
1252
|
+
summary: summarize(totalAdded, totalRemoved),
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (createEditTool) {
|
|
1257
|
+
const origEdit = createEditTool(cwd);
|
|
1258
|
+
|
|
1259
|
+
pi.registerTool({
|
|
1260
|
+
...origEdit,
|
|
1261
|
+
name: "edit",
|
|
1262
|
+
|
|
1263
|
+
async execute(
|
|
1264
|
+
tid: string,
|
|
1265
|
+
params: EditParams,
|
|
1266
|
+
sig: AbortSignal | undefined,
|
|
1267
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1268
|
+
ctx: ExtensionContext,
|
|
1269
|
+
) {
|
|
1270
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
1271
|
+
const operations = getEditOperations(params);
|
|
1272
|
+
const result = (await origEdit.execute(
|
|
1273
|
+
tid,
|
|
1274
|
+
params,
|
|
1275
|
+
sig,
|
|
1276
|
+
upd,
|
|
1277
|
+
ctx,
|
|
1278
|
+
)) as ToolResultLike;
|
|
1279
|
+
|
|
1280
|
+
if (operations.length === 0) return result;
|
|
1281
|
+
|
|
1282
|
+
const { diffs, summary } = summarizeEditOperations(operations);
|
|
1283
|
+
if (operations.length === 1) {
|
|
1284
|
+
let editLine = 0;
|
|
1285
|
+
try {
|
|
1286
|
+
if (fp && existsSync(fp)) {
|
|
1287
|
+
const f = readFileSync(fp, "utf-8");
|
|
1288
|
+
const idx = f.indexOf(operations[0].newText);
|
|
1289
|
+
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
1290
|
+
}
|
|
1291
|
+
} catch {
|
|
1292
|
+
editLine = 0;
|
|
1293
|
+
}
|
|
1294
|
+
setResultDetails(result, {
|
|
1295
|
+
_type: "editInfo",
|
|
1296
|
+
summary,
|
|
1297
|
+
editLine,
|
|
1298
|
+
});
|
|
1299
|
+
return result;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
setResultDetails(result, {
|
|
1303
|
+
_type: "multiEditInfo",
|
|
1304
|
+
summary,
|
|
1305
|
+
editCount: operations.length,
|
|
1306
|
+
diffLineCount: diffs.reduce((sum, d) => sum + d.lines.length, 0),
|
|
1307
|
+
});
|
|
1308
|
+
return result;
|
|
1309
|
+
},
|
|
1310
|
+
|
|
1311
|
+
renderCall(
|
|
1312
|
+
args: EditParams,
|
|
1313
|
+
theme: ThemeLike,
|
|
1314
|
+
ctx: RenderContextLike<EditRenderState>,
|
|
1315
|
+
) {
|
|
1316
|
+
resolveBaseBackground(theme);
|
|
1317
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
1318
|
+
const operations = getEditOperations(args);
|
|
1319
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1320
|
+
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
1321
|
+
|
|
1322
|
+
if (operations.length === 0) {
|
|
1323
|
+
text.setText(fillToolBackground(hdr));
|
|
1324
|
+
return text;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const { summary } = summarizeEditOperations(operations);
|
|
1328
|
+
const suffix =
|
|
1329
|
+
operations.length === 1
|
|
1330
|
+
? summary
|
|
1331
|
+
: `${operations.length} edits ${summary}`;
|
|
1332
|
+
text.setText(
|
|
1333
|
+
fillToolBackground(`${hdr} ${theme.fg("muted", suffix)}`),
|
|
1334
|
+
);
|
|
1335
|
+
return text;
|
|
1336
|
+
},
|
|
1337
|
+
|
|
1338
|
+
renderResult(
|
|
1339
|
+
result: ToolResultLike,
|
|
1340
|
+
_opt: ToolRenderResultOptions,
|
|
1341
|
+
theme: ThemeLike,
|
|
1342
|
+
ctx: RenderContextLike<EditRenderState>,
|
|
1343
|
+
) {
|
|
1344
|
+
resolveBaseBackground(theme);
|
|
1345
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1346
|
+
if (ctx.isError) {
|
|
1347
|
+
text.setText(
|
|
1348
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
1349
|
+
);
|
|
1350
|
+
return text;
|
|
1351
|
+
}
|
|
1352
|
+
const d = result.details as RenderDetails | undefined;
|
|
1353
|
+
if (d?._type === "editInfo") {
|
|
1354
|
+
const loc =
|
|
1355
|
+
d.editLine > 0
|
|
1356
|
+
? ` ${theme.fg("muted", `at line ${d.editLine}`)}`
|
|
1357
|
+
: "";
|
|
1358
|
+
text.setText(fillToolBackground(` ${d.summary}${loc}`));
|
|
1359
|
+
return text;
|
|
1360
|
+
}
|
|
1361
|
+
if (d?._type === "multiEditInfo") {
|
|
1362
|
+
const extra =
|
|
1363
|
+
typeof d.diffLineCount === "number"
|
|
1364
|
+
? ` ${theme.fg("muted", `(${d.diffLineCount} diff lines)`)}`
|
|
1365
|
+
: "";
|
|
1366
|
+
text.setText(
|
|
1367
|
+
fillToolBackground(` ${d.editCount} edits ${d.summary}${extra}`),
|
|
1368
|
+
);
|
|
1369
|
+
return text;
|
|
1370
|
+
}
|
|
1371
|
+
const fallback = result.content?.[0];
|
|
1372
|
+
const fallbackText =
|
|
1373
|
+
fallback && isTextContent(fallback) ? fallback.text : "edited";
|
|
1374
|
+
text.setText(
|
|
1375
|
+
fillToolBackground(
|
|
1376
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
1377
|
+
),
|
|
1378
|
+
);
|
|
1379
|
+
return text;
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// ===================================================================
|
|
1385
|
+
// write — new-file preview + overwrite diff
|
|
1386
|
+
// ===================================================================
|
|
1387
|
+
|
|
1388
|
+
if (createWriteTool) {
|
|
1389
|
+
const origWrite = createWriteTool(cwd);
|
|
1390
|
+
|
|
1391
|
+
pi.registerTool({
|
|
1392
|
+
...origWrite,
|
|
1393
|
+
name: "write",
|
|
1394
|
+
|
|
1395
|
+
async execute(
|
|
1396
|
+
tid: string,
|
|
1397
|
+
params: WriteParams,
|
|
1398
|
+
sig: AbortSignal | undefined,
|
|
1399
|
+
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1400
|
+
ctx: ExtensionContext,
|
|
1401
|
+
) {
|
|
1402
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
1403
|
+
let old: string | null = null;
|
|
1404
|
+
try {
|
|
1405
|
+
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
1406
|
+
} catch {
|
|
1407
|
+
old = null;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const result = (await origWrite.execute(
|
|
1411
|
+
tid,
|
|
1412
|
+
params,
|
|
1413
|
+
sig,
|
|
1414
|
+
upd,
|
|
1415
|
+
ctx,
|
|
1416
|
+
)) as ToolResultLike;
|
|
1417
|
+
const content = params.content ?? "";
|
|
1418
|
+
|
|
1419
|
+
if (old !== null && old !== content) {
|
|
1420
|
+
const diff = parseDiff(old, content);
|
|
1421
|
+
setResultDetails(result, {
|
|
1422
|
+
_type: "diff",
|
|
1423
|
+
summary: summarize(diff.added, diff.removed),
|
|
1424
|
+
oldContent: old,
|
|
1425
|
+
newContent: content,
|
|
1426
|
+
language: lang(fp),
|
|
1427
|
+
});
|
|
1428
|
+
} else if (old === null) {
|
|
1429
|
+
setResultDetails(result, {
|
|
1430
|
+
_type: "new",
|
|
1431
|
+
lines: content ? content.split("\n").length : 0,
|
|
1432
|
+
content,
|
|
1433
|
+
filePath: fp,
|
|
1434
|
+
});
|
|
1435
|
+
} else {
|
|
1436
|
+
setResultDetails(result, { _type: "noChange" });
|
|
1437
|
+
}
|
|
1438
|
+
return result;
|
|
1439
|
+
},
|
|
1440
|
+
|
|
1441
|
+
renderCall(
|
|
1442
|
+
args: WriteParams,
|
|
1443
|
+
theme: ThemeLike,
|
|
1444
|
+
ctx: RenderContextLike<WriteRenderState>,
|
|
1445
|
+
) {
|
|
1446
|
+
resolveBaseBackground(theme);
|
|
1447
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
1448
|
+
const isNew = !fp || !existsSync(fp);
|
|
1449
|
+
const label = isNew ? "create" : "write";
|
|
1450
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1451
|
+
const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
1452
|
+
|
|
1453
|
+
if (args?.content && isNew) {
|
|
1454
|
+
const previewKey = `create:${diffThemeCacheKey(theme)}:${fp}:${String(args.content).length}`;
|
|
1455
|
+
if (ctx.state._previewKey !== previewKey) {
|
|
1456
|
+
ctx.state._previewKey = previewKey;
|
|
1457
|
+
ctx.state._previewText = hdr;
|
|
1458
|
+
const lg = lang(fp);
|
|
1459
|
+
hlBlock(String(args.content), lg)
|
|
1460
|
+
.then((lines) => {
|
|
1461
|
+
if (ctx.state._previewKey !== previewKey) return;
|
|
1462
|
+
const maxShow = ctx.expanded ? lines.length : 16;
|
|
1463
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
1464
|
+
const rem = lines.length - maxShow;
|
|
1465
|
+
let out = `${hdr}\n\n${preview}`;
|
|
1466
|
+
if (rem > 0)
|
|
1467
|
+
out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
1468
|
+
ctx.state._previewText = out;
|
|
1469
|
+
ctx.invalidate();
|
|
1470
|
+
})
|
|
1471
|
+
.catch(() => {});
|
|
1472
|
+
}
|
|
1473
|
+
text.setText(ctx.state._previewText ?? hdr);
|
|
1474
|
+
return text;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
text.setText(fillToolBackground(hdr));
|
|
1478
|
+
return text;
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
renderResult(
|
|
1482
|
+
result: ToolResultLike,
|
|
1483
|
+
_opt: ToolRenderResultOptions,
|
|
1484
|
+
theme: ThemeLike,
|
|
1485
|
+
ctx: RenderContextLike<WriteRenderState>,
|
|
1486
|
+
) {
|
|
1487
|
+
resolveBaseBackground(theme);
|
|
1488
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1489
|
+
if (ctx.isError) {
|
|
1490
|
+
text.setText(
|
|
1491
|
+
renderToolError(getTextContent(result) || "Error", theme),
|
|
1492
|
+
);
|
|
1493
|
+
return text;
|
|
1494
|
+
}
|
|
1495
|
+
const d = result.details as RenderDetails | undefined;
|
|
1496
|
+
|
|
1497
|
+
if (d?._type === "diff") {
|
|
1498
|
+
const key = `wd:${diffThemeCacheKey(theme)}:${termW()}:${d.summary}:${d.newContent.length}:${d.language ?? ""}`;
|
|
1499
|
+
if (ctx.state._wdk !== key) {
|
|
1500
|
+
ctx.state._wdk = key;
|
|
1501
|
+
ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
1502
|
+
const dc = resolveDiffColors(theme);
|
|
1503
|
+
const diff = parseDiff(d.oldContent, d.newContent);
|
|
1504
|
+
renderSplit(diff, d.language, MAX_RENDER_LINES, dc)
|
|
1505
|
+
.then((rendered) => {
|
|
1506
|
+
if (ctx.state._wdk !== key) return;
|
|
1507
|
+
ctx.state._wdt = ` ${d.summary}\n${rendered}`;
|
|
1508
|
+
ctx.invalidate();
|
|
1509
|
+
})
|
|
1510
|
+
.catch(() => {
|
|
1511
|
+
if (ctx.state._wdk !== key) return;
|
|
1512
|
+
ctx.state._wdt = ` ${d.summary}`;
|
|
1513
|
+
ctx.invalidate();
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
1517
|
+
return text;
|
|
1518
|
+
}
|
|
1519
|
+
if (d?._type === "noChange") {
|
|
1520
|
+
text.setText(
|
|
1521
|
+
fillToolBackground(` ${theme.fg("muted", "✓ no changes")}`),
|
|
1522
|
+
);
|
|
1523
|
+
return text;
|
|
1524
|
+
}
|
|
1525
|
+
if (d?._type === "new") {
|
|
1526
|
+
const { lines: lineCount, content: rawContent, filePath: fp } = d;
|
|
1527
|
+
const base = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
1528
|
+
const pk = `nf:${diffThemeCacheKey(theme)}:${fp}:${lineCount}`;
|
|
1529
|
+
if (ctx.state._nfk !== pk) {
|
|
1530
|
+
ctx.state._nfk = pk;
|
|
1531
|
+
ctx.state._nft = base;
|
|
1532
|
+
if (rawContent) {
|
|
1533
|
+
hlBlock(rawContent, lang(fp))
|
|
1534
|
+
.then((hlLines) => {
|
|
1535
|
+
if (ctx.state._nfk !== pk) return;
|
|
1536
|
+
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
1537
|
+
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
1538
|
+
const rem = hlLines.length - maxShow;
|
|
1539
|
+
let out = `${base}\n${preview}`;
|
|
1540
|
+
if (rem > 0)
|
|
1541
|
+
out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
1542
|
+
ctx.state._nft = out;
|
|
1543
|
+
ctx.invalidate();
|
|
1544
|
+
})
|
|
1545
|
+
.catch(() => {});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
text.setText(ctx.state._nft ?? base);
|
|
1549
|
+
return text;
|
|
1550
|
+
}
|
|
1551
|
+
const fallback = result.content?.[0];
|
|
1552
|
+
const fallbackText =
|
|
1553
|
+
fallback && isTextContent(fallback) ? fallback.text : "written";
|
|
1554
|
+
text.setText(
|
|
1555
|
+
fillToolBackground(
|
|
1556
|
+
` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
|
|
1557
|
+
),
|
|
1558
|
+
);
|
|
1559
|
+
return text;
|
|
1560
|
+
},
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// ===================================================================
|
|
1565
|
+
// FFF commands
|
|
1566
|
+
// ===================================================================
|
|
1567
|
+
|
|
1568
|
+
if (fffState.module) {
|
|
1569
|
+
pi.registerCommand("fff-health", {
|
|
1570
|
+
description: "Show FFF file finder health and indexer status",
|
|
1571
|
+
handler: async (_args: string, ctx: CommandContextLike) => {
|
|
1572
|
+
if (!fffState.finder || fffState.finder.isDestroyed) {
|
|
1573
|
+
ctx.ui?.notify?.("FFF not initialized", "warning");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const health = fffState.finder.healthCheck();
|
|
1578
|
+
if (!health.ok) {
|
|
1579
|
+
ctx.ui?.notify?.(`Health check failed: ${health.error}`, "error");
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const h = health.value;
|
|
1584
|
+
const lines = [
|
|
1585
|
+
`FFF v${h.version}`,
|
|
1586
|
+
`Git: ${h.git.repositoryFound ? `yes (${h.git.workdir ?? "unknown"})` : "no"}`,
|
|
1587
|
+
`Picker: ${h.filePicker.initialized ? `${h.filePicker.indexedFiles ?? 0} files` : "not initialized"}`,
|
|
1588
|
+
`Frecency: ${h.frecency.initialized ? "active" : "disabled"}`,
|
|
1589
|
+
`Query tracker: ${h.queryTracker.initialized ? "active" : "disabled"}`,
|
|
1590
|
+
`Partial index: ${fffState.partialIndex ? "yes (scan timed out)" : "no"}`,
|
|
1591
|
+
];
|
|
1592
|
+
|
|
1593
|
+
const progress = fffState.finder.getScanProgress();
|
|
1594
|
+
if (progress.ok) {
|
|
1595
|
+
lines.push(
|
|
1596
|
+
`Scanning: ${progress.value.isScanning ? "yes" : "no"} (${progress.value.scannedFilesCount} files)`,
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
ctx.ui?.notify?.(lines.join("\n"), "info");
|
|
1601
|
+
},
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
pi.registerCommand("fff-rescan", {
|
|
1605
|
+
description: "Trigger FFF to rescan files",
|
|
1606
|
+
handler: async (_args: string, ctx: CommandContextLike) => {
|
|
1607
|
+
if (!fffState.finder || fffState.finder.isDestroyed) {
|
|
1608
|
+
ctx.ui?.notify?.("FFF not initialized", "warning");
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const result = fffState.finder.scanFiles();
|
|
1613
|
+
if (!result.ok) {
|
|
1614
|
+
ctx.ui?.notify?.(`Rescan failed: ${result.error}`, "error");
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
fffState.partialIndex = false;
|
|
1619
|
+
ctx.ui?.notify?.("FFF rescan triggered", "info");
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|