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.
- package/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- 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
|
+
}
|