pi-readseek 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
package/src/write.ts ADDED
@@ -0,0 +1,532 @@
1
+ import { withFileMutationQueue, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, relative } from "node:path";
6
+ import { resolveToCwd } from "./path-utils.js";
7
+ import { ensureHashInit, formatHashlineDisplay } from "./hashline.js";
8
+ import { buildReadseekError, buildReadseekLine, buildReadseekWarning, type ReadseekLine, type ReadseekWarning } from "./readseek-value.js";
9
+ import { looksLikeBinary } from "./binary-detect.js";
10
+ import { getOrGenerateMap } from "./map-cache.js";
11
+ import { formatFileMapWithBudget } from "./readseek/formatter.js";
12
+ import { buildContextHygieneMetadata, buildFileResource, type ContextHygieneMetadata } from "./context-hygiene.js";
13
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
14
+ import { buildPendingWritePreviewData, buildWritePreviewKey, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
15
+ import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from "./edit-diff.js";
16
+ import { buildDiffData, type DiffData } from "./diff-data.js";
17
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
18
+ import { DiffPreviewComponent } from "./tui-diff-component.js";
19
+
20
+ const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
21
+
22
+ const CONTENT_PREVIEW_MAX_LINES = 200;
23
+
24
+ function formatContentPreviewLines(content: string, theme: any): string[] {
25
+ const lines = content.split("\n");
26
+ // Drop the single trailing blank produced by a terminal newline so the
27
+ // preview reads naturally.
28
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
29
+ const shown = lines.slice(0, CONTENT_PREVIEW_MAX_LINES);
30
+ // Right-align line numbers so the body has a stable column for the content.
31
+ // The dim gutter (" N │ ") visually distinguishes the body from the
32
+ // "↳ created / pending create" header above it without re-introducing
33
+ // diff chrome (no +/- marker, no red/green tint).
34
+ const width = String(shown.length).length;
35
+ // Bind theme.fg so we keep its `this` (the Theme uses internal state); fall back
36
+ // to an identity tint when no theme is provided (e.g. in tests).
37
+ const fg = typeof theme?.fg === "function" ? (style: string, text: string) => theme.fg(style, text) : (_style: string, text: string) => text;
38
+ const formatted = shown.map((line, index) => {
39
+ const gutter = fg("dim", `${String(index + 1).padStart(width, " ")} │ `);
40
+ return ` ${gutter}${line}`;
41
+ });
42
+ if (lines.length > CONTENT_PREVIEW_MAX_LINES) {
43
+ formatted.push(` ${fg("dim", `… ${lines.length - CONTENT_PREVIEW_MAX_LINES} more lines not shown`)}`);
44
+ }
45
+ return formatted;
46
+ }
47
+
48
+ function pendingWritePreviewParts(summary: string, preview: PendingDiffPreviewResult | undefined, expanded: boolean, theme: any): { lines: string[]; diffData?: DiffData } {
49
+ if (!preview || preview.type !== "ok") return { lines: summary.split("\n") };
50
+ // Pure creates (write to a new file) have no "old" side, so a diff-shaped
51
+ // preview is just noise. Show the new file's content with a dim gutter of
52
+ // line numbers when expanded; otherwise just a Ctrl+O hint.
53
+ const hasOldSide = preview.data.fileExistedBeforeWrite;
54
+ const headerLine = summaryLine(preview.data.headerLabel, { hidden: !expanded });
55
+ if (!hasOldSide) {
56
+ const lines = [summary, headerLine];
57
+ if (expanded) lines.push(...formatContentPreviewLines(preview.data.nextContent, theme));
58
+ return { lines };
59
+ }
60
+ const diffData = buildDiffData({ path: preview.data.filePath, oldContent: preview.data.previousContent, newContent: preview.data.nextContent, diff: preview.data.diff });
61
+ return { lines: [summary, headerLine], diffData: expanded ? diffData : undefined };
62
+ }
63
+
64
+ const MAX_LINES = 2000;
65
+ const MAX_BYTES = 50 * 1024;
66
+ const WRITE_PROMPT_METADATA = defineToolPromptMetadata({
67
+ promptUrl: new URL("../prompts/write.md", import.meta.url),
68
+ promptSnippet: "Create or overwrite a complete file and return edit anchors",
69
+ promptGuidelines: [
70
+ "Use write to create new files or intentionally replace whole files.",
71
+ "Use edit instead of write for small changes or appends to existing files.",
72
+ "Remember write overwrites existing files without confirmation.",
73
+ ],
74
+ });
75
+
76
+ type WriteDiffFields = {
77
+ diff?: string;
78
+ diffData?: DiffData;
79
+ };
80
+
81
+ export interface WriteResult extends WriteDiffFields {
82
+ text: string;
83
+ warnings: string[];
84
+ writeState?: "created" | "overwritten";
85
+ readseekValue: {
86
+ tool: "write";
87
+ path: string;
88
+ lines: ReadseekLine[];
89
+ warnings: ReadseekWarning[];
90
+ diff?: string;
91
+ diffData?: DiffData;
92
+ map?: { appended: boolean };
93
+ };
94
+ contextHygiene: ContextHygieneMetadata;
95
+ }
96
+
97
+ function readPreviousTextForDiff(filePath: string): string {
98
+ try {
99
+ if (!existsSync(filePath)) return "";
100
+ const previous = readFileSync(filePath);
101
+ if (looksLikeBinary(previous)) return "";
102
+ return previous.toString("utf-8");
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ function generateWriteDiff(previousContent: string, nextContent: string): { diff: string; firstChangedLine: number | undefined } {
109
+ if (previousContent !== "") return generateCompactOrFullDiff(previousContent, nextContent);
110
+ const normalizedNext = normalizeToLF(nextContent);
111
+ if (normalizedNext === "") return { diff: "", firstChangedLine: undefined };
112
+ const lines = normalizedNext.split("\n");
113
+ if (lines[lines.length - 1] === "") lines.pop();
114
+ const width = String(lines.length).length;
115
+ return {
116
+ diff: lines.map((line, index) => `+${String(index + 1).padStart(width, " ")} ${line}`).join("\n"),
117
+ firstChangedLine: 1,
118
+ };
119
+ }
120
+
121
+ export interface WriteToolOptions {
122
+ onFileAnchored?: (absolutePath: string) => void;
123
+ }
124
+
125
+ type MappedFsError = {
126
+ code: "permission-denied" | "path-is-directory" | "fs-error";
127
+ message: string;
128
+ includeMeta: boolean;
129
+ };
130
+
131
+ function mapFsWriteError(err: any, path: string): MappedFsError {
132
+ const phase: "mkdir" | "write" | undefined = err?.__phase;
133
+ const fsCode = err?.code as string | undefined;
134
+
135
+ if (fsCode === "EACCES" || fsCode === "EPERM") {
136
+ return {
137
+ code: "permission-denied",
138
+ message: `Permission denied — cannot write: ${path}`,
139
+ includeMeta: false,
140
+ };
141
+ }
142
+ if (fsCode === "EISDIR") {
143
+ return {
144
+ code: "path-is-directory",
145
+ message: `Path is a directory — cannot overwrite: ${path}`,
146
+ includeMeta: false,
147
+ };
148
+ }
149
+ if (fsCode === "ENOENT" && phase === "mkdir") {
150
+ return {
151
+ code: "fs-error",
152
+ message: `Cannot create parent directories for ${path}: ${err?.message ?? String(err)}`,
153
+ includeMeta: true,
154
+ };
155
+ }
156
+ if (fsCode === "ENOSPC") {
157
+ return {
158
+ code: "fs-error",
159
+ message: `No space left on device — cannot write: ${path}`,
160
+ includeMeta: true,
161
+ };
162
+ }
163
+ if (fsCode === "EROFS") {
164
+ return {
165
+ code: "fs-error",
166
+ message: `Read-only filesystem — cannot write: ${path}`,
167
+ includeMeta: true,
168
+ };
169
+ }
170
+ return {
171
+ code: "fs-error",
172
+ message: `Error writing ${path}: ${err?.message ?? String(err)}`,
173
+ includeMeta: true,
174
+ };
175
+ }
176
+
177
+ export async function executeWrite(opts: {
178
+ path: string;
179
+ content: string;
180
+ map?: boolean;
181
+ cwd?: string;
182
+ }): Promise<WriteResult> {
183
+ await ensureHashInit();
184
+
185
+ const { path: filePath, content, map: requestMap, cwd } = opts;
186
+ const warnings: string[] = [];
187
+ const readseekWarnings: ReadseekWarning[] = [];
188
+ const contextHygiene = buildContextHygieneMetadata({
189
+ tool: "write",
190
+ classification: "mutation",
191
+ resources: [buildFileResource(filePath)],
192
+ });
193
+
194
+ if (hasBareCarriageReturn(content)) {
195
+ const message = "File content contains bare CR (\\r) line endings; write refuses to emit anchors that read/edit would normalize differently.";
196
+ warnings.push(message);
197
+ readseekWarnings.push(buildReadseekWarning("bare-cr", message));
198
+ return {
199
+ text: `Cannot write ${filePath}\n⚠️ ${message}`,
200
+ warnings,
201
+ readseekValue: {
202
+ tool: "write",
203
+ path: filePath,
204
+ lines: [],
205
+ warnings: readseekWarnings,
206
+ },
207
+ contextHygiene,
208
+ };
209
+ }
210
+ const previousContent = readPreviousTextForDiff(filePath);
211
+ const existedBeforeWrite = existsSync(filePath);
212
+
213
+ // Create parent directories
214
+ try {
215
+ mkdirSync(dirname(filePath), { recursive: true });
216
+ } catch (err: any) {
217
+ err.__phase = "mkdir";
218
+ throw err;
219
+ }
220
+ // Write file
221
+ try {
222
+ writeFileSync(filePath, content, "utf-8");
223
+ } catch (err: any) {
224
+ err.__phase = "write";
225
+ throw err;
226
+ }
227
+
228
+
229
+ // Binary detection
230
+ if (looksLikeBinary(Buffer.from(content, "utf-8"))) {
231
+ warnings.push("File content appears to be binary.");
232
+ readseekWarnings.push(buildReadseekWarning("binary-content", "File content appears to be binary."));
233
+ return {
234
+ text: `Wrote ${filePath}\n⚠️ File content appears to be binary — hashlines not generated.`,
235
+ warnings,
236
+ readseekValue: {
237
+ tool: "write",
238
+ path: filePath,
239
+ lines: [],
240
+ warnings: readseekWarnings,
241
+ },
242
+ contextHygiene,
243
+ };
244
+ }
245
+
246
+ // Compute hashlines
247
+ const rawLines = content.split("\n");
248
+ const readseekLines: ReadseekLine[] = [];
249
+ const displayLines: string[] = [];
250
+
251
+ for (let i = 0; i < rawLines.length; i++) {
252
+ const lineNum = i + 1;
253
+ const readseekLine = buildReadseekLine(lineNum, rawLines[i]);
254
+ readseekLines.push(readseekLine);
255
+ displayLines.push(formatHashlineDisplay(lineNum, rawLines[i]));
256
+ }
257
+
258
+ let text = displayLines.join("\n");
259
+ if (rawLines.length > MAX_LINES) {
260
+ text = displayLines.slice(0, MAX_LINES).join("\n");
261
+ text += `\n[… ${rawLines.length - MAX_LINES} more lines not shown — full anchors in readseekValue]`;
262
+ }
263
+ const bytes = Buffer.byteLength(text, "utf8");
264
+ if (bytes > MAX_BYTES) {
265
+ text = Buffer.from(text, "utf8").subarray(0, MAX_BYTES).toString("utf8");
266
+ text += "\n[… output truncated at 50 KB — full anchors in readseekValue]";
267
+ }
268
+
269
+ // Optional structural map
270
+ let mapAppended = false;
271
+ if (requestMap) {
272
+ try {
273
+ const fileMap = await getOrGenerateMap(filePath);
274
+ if (fileMap) {
275
+ const mapText = formatFileMapWithBudget(fileMap);
276
+ if (mapText) {
277
+ text += "\n\n" + mapText;
278
+ mapAppended = true;
279
+ }
280
+ }
281
+ } catch {
282
+ // Map generation failure is non-fatal
283
+ }
284
+ }
285
+
286
+ const displayPath = cwd ? relative(cwd, filePath) || filePath : filePath;
287
+ const normalizedPrevious = normalizeToLF(previousContent);
288
+ const normalizedNext = normalizeToLF(content);
289
+ const diffResult = generateWriteDiff(normalizedPrevious, normalizedNext);
290
+ const diffData = buildDiffData({
291
+ path: filePath,
292
+ oldContent: normalizedPrevious,
293
+ newContent: normalizedNext,
294
+ diff: diffResult.diff,
295
+ });
296
+
297
+ return {
298
+ text,
299
+ warnings,
300
+ writeState: existedBeforeWrite ? "overwritten" : "created",
301
+ diff: diffResult.diff,
302
+ diffData,
303
+ readseekValue: {
304
+ tool: "write",
305
+ path: displayPath,
306
+ lines: readseekLines,
307
+ warnings: readseekWarnings,
308
+ diff: diffResult.diff,
309
+ diffData,
310
+ ...(requestMap !== undefined ? { map: { appended: mapAppended } } : {}),
311
+ },
312
+ contextHygiene,
313
+ };
314
+ }
315
+
316
+ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions = {}) {
317
+ const toolConfig = {
318
+ callable: true,
319
+ enabled: true,
320
+ policy: "mutating" as const,
321
+ readOnly: false,
322
+ pythonName: "write",
323
+ defaultExposure: "not-safe-by-default" as const,
324
+ };
325
+ const tool = {
326
+ name: "write",
327
+ label: "write",
328
+ description: WRITE_PROMPT_METADATA.description,
329
+ promptSnippet: WRITE_PROMPT_METADATA.promptSnippet,
330
+ promptGuidelines: WRITE_PROMPT_METADATA.promptGuidelines,
331
+ ptc: toolConfig,
332
+ parameters: Type.Object({
333
+ path: Type.String({ description: "File path" }),
334
+ content: Type.String({ description: "File content" }),
335
+ map: Type.Optional(Type.Boolean({ description: "Append structural map" })),
336
+ }),
337
+ async execute(_toolCallId: string, params: { path: string; content: string; map?: boolean }, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any): Promise<any> {
338
+ const cwd = ctx?.cwd ?? process.cwd();
339
+ const absolutePath = resolveToCwd(params.path, cwd);
340
+ try {
341
+ return await withFileMutationQueue(absolutePath, async () => {
342
+ let result: WriteResult;
343
+ try {
344
+ result = await executeWrite({
345
+ path: absolutePath,
346
+ content: params.content,
347
+ map: params.map,
348
+ cwd,
349
+ });
350
+ } catch (err: any) {
351
+ const mapped = mapFsWriteError(err, absolutePath);
352
+ return {
353
+ content: [{ type: "text" as const, text: mapped.message }],
354
+ isError: true,
355
+ details: {
356
+ readseekValue: {
357
+ tool: "write" as const,
358
+ path: absolutePath,
359
+ lines: [] as ReadseekLine[],
360
+ warnings: [] as ReadseekWarning[],
361
+ ok: false,
362
+ error: buildReadseekError(
363
+ mapped.code,
364
+ mapped.message,
365
+ undefined,
366
+ mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
367
+ ),
368
+ },
369
+ warnings: [] as string[],
370
+ },
371
+ };
372
+ }
373
+
374
+ if (result.readseekValue.lines.length > 0) {
375
+ options.onFileAnchored?.(absolutePath);
376
+ }
377
+
378
+ // Lift binary-content signal into a fatal readseekValue.error envelope so
379
+ // downstream consumers get the same taxonomy shape as every other tool.
380
+ // The existing ReadseekWarning entry is preserved on readseekValue.warnings for
381
+ // backward compatibility (see AC 12 — warnings namespace alignment).
382
+ const binaryWarning = result.readseekValue.warnings.find((w) => w.code === "binary-content");
383
+ if (binaryWarning) {
384
+ return {
385
+ content: [{ type: "text" as const, text: result.text }],
386
+ isError: true,
387
+ details: {
388
+ readseekValue: {
389
+ ...result.readseekValue,
390
+ ok: false,
391
+ error: buildReadseekError("binary-content", binaryWarning.message),
392
+ },
393
+ warnings: result.warnings,
394
+ contextHygiene: result.contextHygiene,
395
+ },
396
+ };
397
+ }
398
+
399
+ const bareCrWarning = result.readseekValue.warnings.find((w) => w.code === "bare-cr");
400
+ if (bareCrWarning) {
401
+ return {
402
+ content: [{ type: "text" as const, text: result.text }],
403
+ isError: true,
404
+ details: {
405
+ readseekValue: {
406
+ ...result.readseekValue,
407
+ ok: false,
408
+ error: buildReadseekError("bare-cr", bareCrWarning.message),
409
+ },
410
+ warnings: result.warnings,
411
+ contextHygiene: result.contextHygiene,
412
+ },
413
+ };
414
+ }
415
+
416
+ return {
417
+ content: [{ type: "text" as const, text: result.text }],
418
+ details: {
419
+ ...(result.diff !== undefined ? { diff: result.diff } : {}),
420
+ ...(result.diffData !== undefined ? { diffData: result.diffData } : {}),
421
+ ...(result.writeState ? { writeState: result.writeState } : {}),
422
+ readseekValue: result.readseekValue,
423
+ warnings: result.warnings,
424
+ contextHygiene: result.contextHygiene,
425
+ },
426
+ };
427
+ });
428
+ } catch (err: any) {
429
+ const mapped = mapFsWriteError(err, absolutePath);
430
+ return {
431
+ content: [{ type: "text" as const, text: mapped.message }],
432
+ isError: true,
433
+ details: {
434
+ readseekValue: {
435
+ tool: "write" as const,
436
+ path: absolutePath,
437
+ lines: [] as ReadseekLine[],
438
+ warnings: [] as ReadseekWarning[],
439
+ ok: false,
440
+ error: buildReadseekError(
441
+ mapped.code,
442
+ mapped.message,
443
+ undefined,
444
+ mapped.includeMeta ? { fsCode: err?.code, fsMessage: err?.message } : undefined,
445
+ ),
446
+ },
447
+ warnings: [] as string[],
448
+ },
449
+ };
450
+ }
451
+ },
452
+ renderCall(args: any, theme: any, context: any = {}) {
453
+ const { path, content } = args as { path: string; content?: string };
454
+ const cwd = context.cwd ?? process.cwd();
455
+ const label = renderToolLabel(theme, "write");
456
+ const lineCount = typeof content === "string" ? content.split("\n").length : 0;
457
+ const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : 0;
458
+ const renderedPath = typeof path === "string"
459
+ ? linkToolPath(theme.fg("muted", path), path, cwd)
460
+ : theme.fg("toolOutput", "...");
461
+ let text = clampLineToWidth(`${label} ${renderedPath}${typeof content === "string" ? ` (${lineCount} ${lineCount === 1 ? "line" : "lines"} • ${bytes} B)` : ""}`, context.width);
462
+ // Once execution has started, the pending preview's only job is done:
463
+ // renderResult will carry the story ("↳ created" / "↳ overwritten" with
464
+ // expandable content or diff). Showing the "↳ pending…" sub-line and
465
+ // its preview alongside the final result is just duplicate noise — the
466
+ // pre-execution state can no longer change in any meaningful way.
467
+ if (context.executionStarted) {
468
+ const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
469
+ ? context.lastComponent
470
+ : new Text("", 0, 0);
471
+ textComponent.setText(text);
472
+ return textComponent;
473
+ }
474
+ const previewKey = buildWritePreviewKey(args ?? {});
475
+ const preview = resolvePendingDiffPreview(context, WRITE_PENDING_PREVIEW_STATE_KEY, previewKey, () => buildPendingWritePreviewData(args ?? {}, context.cwd ?? process.cwd()));
476
+ const expanded = !!context.expanded;
477
+ const parts = pendingWritePreviewParts(text, preview, expanded, theme);
478
+ if (parts.diffData) {
479
+ const diffComponent = context.lastComponent instanceof DiffPreviewComponent
480
+ ? context.lastComponent
481
+ : new DiffPreviewComponent({ prefixLines: parts.lines, diffData: parts.diffData, theme, expanded: true });
482
+ diffComponent.update({ prefixLines: parts.lines, diffData: parts.diffData, theme, expanded: true });
483
+ return diffComponent;
484
+ }
485
+ const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
486
+ ? context.lastComponent
487
+ : new Text("", 0, 0);
488
+ textComponent.setText(clampLinesToWidth(parts.lines, context.width).join("\n"));
489
+ return textComponent;
490
+ },
491
+ renderResult(result: any, options: any, theme: any, context: any = {}) {
492
+ const expanded = isRendererExpanded(options, context);
493
+ const width = context.width ?? options?.width;
494
+ const details = result.details ?? {};
495
+ const output = result.content?.[0]?.type === "text" ? result.content[0].text : "";
496
+ if (result.isError || details.readseekValue?.ok === false) {
497
+ const firstLine = output.split("\n")[0] || "write failed";
498
+ const body = expanded && output ? output : firstLine;
499
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
500
+ }
501
+ const diffData = details.diffData;
502
+ const state = details.writeState === "overwritten" ? "overwritten" : "created";
503
+ // Pure creates: render the new file's contents on expand (no diff chrome)
504
+ // instead of a diff body — every line is an add, so the gutter, line
505
+ // numbers, and red/green tinting are noise.
506
+ if (state === "created") {
507
+ const readseekLines = (details.readseekValue?.lines ?? []) as Array<{ raw: string }>;
508
+ const hasContent = readseekLines.length > 0;
509
+ const header = summaryLine(state, { hidden: hasContent && !expanded });
510
+ const lines = header.split("\n");
511
+ if (expanded && hasContent) {
512
+ const content = readseekLines.map((l) => l.raw).join("\n");
513
+ lines.push(...formatContentPreviewLines(content, theme));
514
+ }
515
+ return new Text(clampLinesToWidth(lines, width).join("\n"), 0, 0);
516
+ }
517
+ // Overwrite: the old vs new comparison still carries signal — keep the diff UI.
518
+ const hasExpandableDiff = !!diffData;
519
+ let text = summaryLine(state, { hidden: hasExpandableDiff && !expanded });
520
+ if (expanded && hasExpandableDiff) {
521
+ const diffComponent = context.lastComponent instanceof DiffPreviewComponent
522
+ ? context.lastComponent
523
+ : new DiffPreviewComponent({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
524
+ diffComponent.update({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
525
+ return diffComponent;
526
+ }
527
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
528
+ },
529
+ } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
530
+ pi.registerTool(tool);
531
+ return tool;
532
+ }