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/edit.ts
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { withFileMutationQueue, type ExtensionAPI, type EditToolDetails, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { Static } from "@sinclair/typebox";
|
|
4
|
+
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
5
|
+
import { readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
|
|
6
|
+
import { createPatch } from "diff";
|
|
7
|
+
import { detectLineEnding, generateCompactOrFullDiff, normalizeToLF, replaceText, restoreLineEndings, stripBom } from "./edit-diff.js";
|
|
8
|
+
import { HashlineMismatchError, applyHashlineEdits, computeLineHash, ensureHashInit, parseLineRef, type HashlineEditItem, escapeControlCharsForDisplay } from "./hashline.js";
|
|
9
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
10
|
+
import { throwIfAborted } from "./runtime.js";
|
|
11
|
+
import { buildEditOutput } from "./edit-output.js";
|
|
12
|
+
import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js";
|
|
13
|
+
import type { SemanticSummary } from "./readseek-value.js";
|
|
14
|
+
import { buildReadseekError } from "./readseek-value.js";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { countEditTypes, formatEditCallText, formatEditResultText } from "./edit-render-helpers.js";
|
|
17
|
+
import { validateSyntaxRegression } from "./edit-syntax-validate.js";
|
|
18
|
+
import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-validate-mode.js";
|
|
19
|
+
import { replaceSymbol } from "./replace-symbol.js";
|
|
20
|
+
import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js";
|
|
21
|
+
import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
|
|
22
|
+
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, summaryLine } from "./tui-render-utils.js";
|
|
23
|
+
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
24
|
+
import { buildContextHygieneMetadata, buildFileResource, type ContextHygieneMetadata } from "./context-hygiene.js";
|
|
25
|
+
import { resolveEditDiffDisplay } from "./readseek-settings.js";
|
|
26
|
+
|
|
27
|
+
const EDIT_PENDING_PREVIEW_STATE_KEY = "hashline-edit-pending-preview";
|
|
28
|
+
|
|
29
|
+
function pendingPreviewLines(summary: string, preview: PendingDiffPreviewResult | undefined, expanded: boolean): { lines: string[]; diffData?: ReturnType<typeof buildDiffData>; headerLabel?: string } {
|
|
30
|
+
if (!preview || preview.type !== "ok") return { lines: summary.split("\n") };
|
|
31
|
+
const diffData = buildDiffData({ path: preview.data.filePath, oldContent: preview.data.previousContent, newContent: preview.data.nextContent, diff: preview.data.diff });
|
|
32
|
+
const headerLine = summaryLine(preview.data.headerLabel, { hidden: !expanded });
|
|
33
|
+
return { lines: [summary, headerLine], diffData: expanded ? diffData : undefined, headerLabel: preview.data.headerLabel };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function wrapWriteError(err: any, path: string): Error {
|
|
37
|
+
const code = err?.code;
|
|
38
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
39
|
+
return new Error(`Permission denied: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
return new Error(`Failed to write file: ${path}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isBinaryBuffer(buf: Buffer): boolean {
|
|
45
|
+
return buf.includes(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Schema ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const hashlineEditItemSchema = Type.Union([
|
|
51
|
+
Type.Object({ set_line: Type.Object({ anchor: Type.String(), new_text: Type.String() }) }, { additionalProperties: true }),
|
|
52
|
+
Type.Object(
|
|
53
|
+
{ replace_lines: Type.Object({ start_anchor: Type.String(), end_anchor: Type.String(), new_text: Type.String() }) },
|
|
54
|
+
{ additionalProperties: true },
|
|
55
|
+
),
|
|
56
|
+
Type.Object({ insert_after: Type.Object({ anchor: Type.String(), new_text: Type.String(), text: Type.Optional(Type.String()) }) }, { additionalProperties: true }),
|
|
57
|
+
Type.Object(
|
|
58
|
+
{ replace: Type.Object({ old_text: Type.String(), new_text: Type.String(), all: Type.Optional(Type.Boolean()), fuzzy: Type.Optional(Type.Boolean()) }) },
|
|
59
|
+
{ additionalProperties: true },
|
|
60
|
+
),
|
|
61
|
+
Type.Object(
|
|
62
|
+
{ replace_symbol: Type.Object({ symbol: Type.String(), new_body: Type.String() }) },
|
|
63
|
+
{ additionalProperties: true },
|
|
64
|
+
),
|
|
65
|
+
Type.Object(
|
|
66
|
+
{ old_text: Type.String(), new_text: Type.String() },
|
|
67
|
+
{
|
|
68
|
+
additionalProperties: true,
|
|
69
|
+
description: "Do not use — Wrap as { replace: {old_text, new_text} }.",
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const hashlineEditSchema = Type.Object(
|
|
75
|
+
{
|
|
76
|
+
path: Type.String({ description: "File path" }),
|
|
77
|
+
edits: Type.Optional(Type.Array(hashlineEditItemSchema, { description: "Edit operations" })),
|
|
78
|
+
postEditVerify: Type.Optional(Type.Boolean({
|
|
79
|
+
description: "Verify persisted content after write",
|
|
80
|
+
})),
|
|
81
|
+
},
|
|
82
|
+
{ additionalProperties: true },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
type HashlineParams = Static<typeof hashlineEditSchema>;
|
|
86
|
+
|
|
87
|
+
const EDIT_PROMPT_METADATA = defineToolPromptMetadata({
|
|
88
|
+
promptUrl: new URL("../prompts/edit.md", import.meta.url),
|
|
89
|
+
promptSnippet: "Edit files using hash-verified anchors from read/grep/search/write",
|
|
90
|
+
promptGuidelines: [
|
|
91
|
+
"Use edit for changes to existing files; read or search first and copy fresh LINE:HASH anchors.",
|
|
92
|
+
"Prefer edit anchored set_line, replace_lines, and insert_after over shell rewrites.",
|
|
93
|
+
"Use edit replace only when anchored edits are impractical.",
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function buildEditError(
|
|
98
|
+
path: string,
|
|
99
|
+
code: string,
|
|
100
|
+
message: string,
|
|
101
|
+
hint?: string,
|
|
102
|
+
errorDetails?: Record<string, unknown>,
|
|
103
|
+
contextHygiene?: ContextHygieneMetadata,
|
|
104
|
+
): {
|
|
105
|
+
content: [{ type: "text"; text: string }];
|
|
106
|
+
isError: true;
|
|
107
|
+
details: EditToolDetails & { readseekValue: any; contextHygiene?: ContextHygieneMetadata };
|
|
108
|
+
} {
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text", text: message }],
|
|
111
|
+
isError: true,
|
|
112
|
+
details: {
|
|
113
|
+
diff: "",
|
|
114
|
+
patch: "",
|
|
115
|
+
firstChangedLine: undefined,
|
|
116
|
+
readseekValue: {
|
|
117
|
+
tool: "edit",
|
|
118
|
+
ok: false,
|
|
119
|
+
path,
|
|
120
|
+
error: buildReadseekError(code, message, hint, errorDetails),
|
|
121
|
+
},
|
|
122
|
+
...(contextHygiene ? { contextHygiene } : {}),
|
|
123
|
+
} as EditToolDetails & { readseekValue: any; contextHygiene?: ContextHygieneMetadata },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface EditToolOptions {
|
|
128
|
+
wasReadInSession?: (absolutePath: string) => boolean;
|
|
129
|
+
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Registration ───────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) {
|
|
135
|
+
const toolConfig = {
|
|
136
|
+
callable: true,
|
|
137
|
+
enabled: true,
|
|
138
|
+
policy: "mutating" as const,
|
|
139
|
+
readOnly: false,
|
|
140
|
+
pythonName: "edit",
|
|
141
|
+
defaultExposure: "not-safe-by-default" as const,
|
|
142
|
+
};
|
|
143
|
+
const tool = {
|
|
144
|
+
name: "edit",
|
|
145
|
+
label: "Edit",
|
|
146
|
+
description: EDIT_PROMPT_METADATA.description,
|
|
147
|
+
promptSnippet: EDIT_PROMPT_METADATA.promptSnippet,
|
|
148
|
+
promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines,
|
|
149
|
+
parameters: hashlineEditSchema,
|
|
150
|
+
ptc: toolConfig,
|
|
151
|
+
renderShell: "default" as const,
|
|
152
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
153
|
+
await ensureHashInit();
|
|
154
|
+
const parsed = params as HashlineParams;
|
|
155
|
+
const input = params as Record<string, unknown>;
|
|
156
|
+
const rawPath = parsed.path;
|
|
157
|
+
const path = rawPath.replace(/^@/, "");
|
|
158
|
+
const absolutePath = resolveToCwd(path, ctx.cwd);
|
|
159
|
+
throwIfAborted(signal);
|
|
160
|
+
try {
|
|
161
|
+
return await withFileMutationQueue(absolutePath, async () => {
|
|
162
|
+
throwIfAborted(signal);
|
|
163
|
+
if (options.wasReadInSession && !options.wasReadInSession(absolutePath)) {
|
|
164
|
+
const message = [
|
|
165
|
+
`You must get fresh anchors for ${absolutePath} before editing it.`,
|
|
166
|
+
`Call read(${JSON.stringify(rawPath)}) first, or use grep, search, or write to produce fresh anchors for this file.`,
|
|
167
|
+
"edit requires fresh LINE:HASH anchors from read, grep, search, or write so the hashes match the current file contents.",
|
|
168
|
+
].join(" ");
|
|
169
|
+
return buildEditError(
|
|
170
|
+
absolutePath,
|
|
171
|
+
"file-not-read",
|
|
172
|
+
message,
|
|
173
|
+
`Call read(${JSON.stringify(rawPath)}) first, or use grep, search, or write to produce fresh anchors for this file.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const hasTopLevelReplaceInput =
|
|
177
|
+
typeof input.oldText === "string" ||
|
|
178
|
+
typeof input.newText === "string" ||
|
|
179
|
+
typeof input.old_text === "string" ||
|
|
180
|
+
typeof input.new_text === "string";
|
|
181
|
+
if (hasTopLevelReplaceInput) {
|
|
182
|
+
return buildEditError(
|
|
183
|
+
absolutePath,
|
|
184
|
+
"invalid-edit-variant",
|
|
185
|
+
"Top-level oldText/newText and old_text/new_text are no longer supported. Use edits[0].replace instead.",
|
|
186
|
+
"Use edits: [{ replace: { old_text, new_text } }].",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const edits = parsed.edits ?? [];
|
|
191
|
+
|
|
192
|
+
if (!edits.length) {
|
|
193
|
+
return buildEditError(absolutePath, "invalid-edit-variant", "No edits provided.");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate edit variant keys
|
|
197
|
+
for (let i = 0; i < edits.length; i++) {
|
|
198
|
+
throwIfAborted(signal);
|
|
199
|
+
const e = edits[i] as Record<string, unknown>;
|
|
200
|
+
if (("old_text" in e || "new_text" in e) && !("replace" in e)) {
|
|
201
|
+
const message = `edits[${i}] has top-level 'old_text'/'new_text'. Use {replace: {old_text, new_text}} or {set_line}, {replace_lines}, {insert_after}.`;
|
|
202
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
203
|
+
}
|
|
204
|
+
if ("diff" in e) {
|
|
205
|
+
const message = `edits[${i}] contains 'diff' from patch mode. Hashline edit expects one of: {set_line}, {replace_lines}, {insert_after}, {replace}.`;
|
|
206
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
207
|
+
}
|
|
208
|
+
const variantCount =
|
|
209
|
+
Number("set_line" in e) +
|
|
210
|
+
Number("replace_lines" in e) +
|
|
211
|
+
Number("insert_after" in e) +
|
|
212
|
+
Number("replace" in e) +
|
|
213
|
+
Number("replace_symbol" in e);
|
|
214
|
+
if (variantCount !== 1) {
|
|
215
|
+
const message = `edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', 'replace', 'replace_symbol'. Got: [${Object.keys(e).join(", ")}].`;
|
|
216
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const anchorEdits = edits.filter(
|
|
221
|
+
(e): e is HashlineEditItem => "set_line" in e || "replace_lines" in e || "insert_after" in e,
|
|
222
|
+
);
|
|
223
|
+
const replaceEdits = edits.filter(
|
|
224
|
+
(e): e is { replace: { old_text: string; new_text: string; all?: boolean; fuzzy?: boolean } } => "replace" in e,
|
|
225
|
+
);
|
|
226
|
+
const replaceSymbolEdits = edits.filter(
|
|
227
|
+
(e): e is { replace_symbol: { symbol: string; new_body: string } } => "replace_symbol" in e,
|
|
228
|
+
);
|
|
229
|
+
for (const rs of replaceSymbolEdits) {
|
|
230
|
+
if (!rs.replace_symbol.new_body.trim()) {
|
|
231
|
+
return buildEditError(absolutePath, "invalid-edit-variant", "replace_symbol.new_body must not be empty or whitespace-only.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let rawBuffer: Buffer;
|
|
236
|
+
try {
|
|
237
|
+
rawBuffer = await fsReadFile(absolutePath);
|
|
238
|
+
} catch (err: any) {
|
|
239
|
+
const code = err?.code;
|
|
240
|
+
let errCode: string;
|
|
241
|
+
let message: string;
|
|
242
|
+
let hint: string | undefined;
|
|
243
|
+
let errorDetails: { fsCode?: string; fsMessage?: string } | undefined;
|
|
244
|
+
if (code === "EISDIR") {
|
|
245
|
+
errCode = "path-is-directory";
|
|
246
|
+
message = `Path is a directory: ${path}`;
|
|
247
|
+
hint = `Use ls(${JSON.stringify(path)}) to inspect directories.`;
|
|
248
|
+
} else if (code === "ENOENT") {
|
|
249
|
+
errCode = "file-not-found";
|
|
250
|
+
message = `File not found: ${path}`;
|
|
251
|
+
} else if (code === "EACCES" || code === "EPERM") {
|
|
252
|
+
errCode = "permission-denied";
|
|
253
|
+
message = `Permission denied: ${path}`;
|
|
254
|
+
} else {
|
|
255
|
+
errCode = "fs-error";
|
|
256
|
+
message = `File not readable: ${path}${err?.message ? ` — ${err.message}` : ""}`;
|
|
257
|
+
errorDetails = { fsCode: code, fsMessage: err?.message };
|
|
258
|
+
}
|
|
259
|
+
return buildEditError(absolutePath, errCode, message, hint, errorDetails);
|
|
260
|
+
}
|
|
261
|
+
if (isBinaryBuffer(rawBuffer)) {
|
|
262
|
+
const message = `Cannot edit binary file: ${path}`;
|
|
263
|
+
return buildEditError(absolutePath, "binary-file", message);
|
|
264
|
+
}
|
|
265
|
+
throwIfAborted(signal);
|
|
266
|
+
const raw = rawBuffer.toString("utf-8");
|
|
267
|
+
const { bom, text: content } = stripBom(raw);
|
|
268
|
+
const originalEnding = detectLineEnding(content);
|
|
269
|
+
const originalNormalized = normalizeToLF(content);
|
|
270
|
+
let preAnchorContent = originalNormalized;
|
|
271
|
+
// AC 26: reject anchored edits that target a line inside any replace_symbol
|
|
272
|
+
// pre-replace range. Resolve each target against the ORIGINAL content so the
|
|
273
|
+
// user-provided anchor line numbers (which reference the file as read) are
|
|
274
|
+
// compared against the pre-replace coordinates.
|
|
275
|
+
//
|
|
276
|
+
// F2: surface replace_symbol symbol-resolution errors (not-found, ambiguous)
|
|
277
|
+
// BEFORE the AC 26 overlap check and before any write (C1 preserved).
|
|
278
|
+
// Error-precedence order: replace_symbol resolution > anchor-overlap > anchored-edit.
|
|
279
|
+
//
|
|
280
|
+
// AC 4: store successful probe results and reuse them in the apply loop so
|
|
281
|
+
// generateMapFromContent is invoked at most once per replace_symbol edit.
|
|
282
|
+
const replaceSymbolRanges: { start: number; end: number }[] = [];
|
|
283
|
+
const rsProbeResults: { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }[] = [];
|
|
284
|
+
for (const rs of replaceSymbolEdits) {
|
|
285
|
+
const probe = await replaceSymbol({
|
|
286
|
+
filePath: absolutePath,
|
|
287
|
+
content: originalNormalized,
|
|
288
|
+
symbol: rs.replace_symbol.symbol,
|
|
289
|
+
newBody: rs.replace_symbol.new_body,
|
|
290
|
+
});
|
|
291
|
+
if (probe.type !== "ok") {
|
|
292
|
+
// F2: symbol-resolution errors surface before AC 26 overlap check.
|
|
293
|
+
return buildEditError(absolutePath, "invalid-edit-variant", probe.message);
|
|
294
|
+
}
|
|
295
|
+
rsProbeResults.push(probe);
|
|
296
|
+
replaceSymbolRanges.push(probe.range);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const sortedReplaceSymbolRanges = [...replaceSymbolRanges].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
300
|
+
for (let i = 1; i < sortedReplaceSymbolRanges.length; i++) {
|
|
301
|
+
const prev = sortedReplaceSymbolRanges[i - 1];
|
|
302
|
+
const current = sortedReplaceSymbolRanges[i];
|
|
303
|
+
if (current.start <= prev.end) {
|
|
304
|
+
const message = `replace_symbol ranges overlap or duplicate (lines ${prev.start}-${prev.end} and ${current.start}-${current.end}).`;
|
|
305
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (replaceSymbolRanges.length > 0) {
|
|
309
|
+
for (const edit of anchorEdits) {
|
|
310
|
+
if ("replace_lines" in edit) {
|
|
311
|
+
let startLine: number | undefined;
|
|
312
|
+
let endLine: number | undefined;
|
|
313
|
+
try {
|
|
314
|
+
startLine = parseLineRef((edit as any).replace_lines.start_anchor).line;
|
|
315
|
+
endLine = parseLineRef((edit as any).replace_lines.end_anchor).line;
|
|
316
|
+
} catch {
|
|
317
|
+
// Let the normal anchored edit validation report malformed anchors later.
|
|
318
|
+
}
|
|
319
|
+
if (startLine !== undefined && endLine !== undefined) {
|
|
320
|
+
const lo = Math.min(startLine, endLine);
|
|
321
|
+
const hi = Math.max(startLine, endLine);
|
|
322
|
+
for (const range of replaceSymbolRanges) {
|
|
323
|
+
if (lo <= range.end && hi >= range.start) {
|
|
324
|
+
const message = `replace_lines range ${lo}-${hi} overlaps a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
325
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const refs: string[] = [];
|
|
331
|
+
if ("set_line" in edit) refs.push((edit as any).set_line.anchor);
|
|
332
|
+
else if ("replace_lines" in edit) {
|
|
333
|
+
refs.push((edit as any).replace_lines.start_anchor, (edit as any).replace_lines.end_anchor);
|
|
334
|
+
} else if ("insert_after" in edit) refs.push((edit as any).insert_after.anchor);
|
|
335
|
+
for (const ref of refs) {
|
|
336
|
+
let parsedLine: number | undefined;
|
|
337
|
+
try {
|
|
338
|
+
parsedLine = parseLineRef(ref).line;
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const range of replaceSymbolRanges) {
|
|
343
|
+
if (parsedLine >= range.start && parsedLine <= range.end) {
|
|
344
|
+
const message = `Anchor at line ${parsedLine} falls inside a replace_symbol range (lines ${range.start}-${range.end}).`;
|
|
345
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Apply pass: reuse all probe results (AC 4). The probe pass resolved every
|
|
352
|
+
// replace_symbol against originalNormalized; apply those replacements in
|
|
353
|
+
// reverse source order so original line ranges stay valid and no second
|
|
354
|
+
// replaceSymbol/generateMapFromContent call is needed.
|
|
355
|
+
const replaceSymbolWarnings: string[] = [];
|
|
356
|
+
if (rsProbeResults.length > 0) {
|
|
357
|
+
const lines = originalNormalized.split("\n");
|
|
358
|
+
for (const probe of rsProbeResults) {
|
|
359
|
+
replaceSymbolWarnings.push(...probe.warnings);
|
|
360
|
+
}
|
|
361
|
+
for (const probe of [...rsProbeResults].sort((a, b) => b.range.start - a.range.start)) {
|
|
362
|
+
lines.splice(
|
|
363
|
+
probe.range.start - 1,
|
|
364
|
+
probe.range.end - probe.range.start + 1,
|
|
365
|
+
...probe.replacement.split("\n"),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
preAnchorContent = lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
let result = preAnchorContent;
|
|
371
|
+
|
|
372
|
+
let anchorResult;
|
|
373
|
+
try {
|
|
374
|
+
anchorResult = applyHashlineEdits(result, anchorEdits, signal);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof HashlineMismatchError) {
|
|
377
|
+
return buildEditError(absolutePath, "hash-mismatch", err.message, undefined, {
|
|
378
|
+
updatedAnchors: err.updatedAnchors,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
result = anchorResult.content;
|
|
384
|
+
|
|
385
|
+
const replaceWarnings: string[] = [];
|
|
386
|
+
for (const r of replaceEdits) {
|
|
387
|
+
throwIfAborted(signal);
|
|
388
|
+
if (!r.replace.old_text.length) {
|
|
389
|
+
const message = "replace.old_text must not be empty.";
|
|
390
|
+
return buildEditError(absolutePath, "invalid-edit-variant", message);
|
|
391
|
+
}
|
|
392
|
+
const rep = replaceText(result, r.replace.old_text, r.replace.new_text, {
|
|
393
|
+
all: r.replace.all ?? false,
|
|
394
|
+
fuzzy: r.replace.fuzzy ?? false,
|
|
395
|
+
});
|
|
396
|
+
if (!rep.count) {
|
|
397
|
+
const message = `Could not find exact text to replace in ${path}.`;
|
|
398
|
+
const hint =
|
|
399
|
+
"Re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits. " +
|
|
400
|
+
"The replace variant is exact-only by default because fuzzy fallback is unverified.";
|
|
401
|
+
return buildEditError(absolutePath, "text-not-found", message, hint);
|
|
402
|
+
}
|
|
403
|
+
if (rep.usedFuzzyMatch) {
|
|
404
|
+
replaceWarnings.push(
|
|
405
|
+
"replace used fuzzy matching because exact old_text was not found; re-read the file and prefer set_line/replace_lines/insert_after for hash-verified edits.",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
result = rep.content;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (originalNormalized === result) {
|
|
412
|
+
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
413
|
+
if (anchorResult.noopEdits?.length) {
|
|
414
|
+
diagnostic +=
|
|
415
|
+
"\n" +
|
|
416
|
+
anchorResult.noopEdits
|
|
417
|
+
.map(
|
|
418
|
+
(e) =>
|
|
419
|
+
`Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${escapeControlCharsForDisplay(e.currentContent)}`,
|
|
420
|
+
)
|
|
421
|
+
.join("\n");
|
|
422
|
+
diagnostic += "\nRe-read the file to see the current state.";
|
|
423
|
+
} else {
|
|
424
|
+
// Edits were not literally identical but heuristics normalized them back
|
|
425
|
+
const lines = result.split("\n");
|
|
426
|
+
const targetLines: string[] = [];
|
|
427
|
+
for (const edit of edits) {
|
|
428
|
+
const refs: string[] = [];
|
|
429
|
+
if ("set_line" in edit) refs.push((edit as any).set_line.anchor);
|
|
430
|
+
else if ("replace_lines" in edit) {
|
|
431
|
+
refs.push((edit as any).replace_lines.start_anchor, (edit as any).replace_lines.end_anchor);
|
|
432
|
+
} else if ("insert_after" in edit) refs.push((edit as any).insert_after.anchor);
|
|
433
|
+
for (const ref of refs) {
|
|
434
|
+
try {
|
|
435
|
+
const parsed = parseLineRef(ref);
|
|
436
|
+
if (parsed.line >= 1 && parsed.line <= lines.length) {
|
|
437
|
+
const lineContent = lines[parsed.line - 1];
|
|
438
|
+
const hash = computeLineHash(parsed.line, lineContent);
|
|
439
|
+
targetLines.push(`${parsed.line}:${hash}|${escapeControlCharsForDisplay(lineContent)}`);
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
/* skip malformed refs */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (targetLines.length > 0) {
|
|
447
|
+
const preview = [...new Set(targetLines)].slice(0, 5).join("\n");
|
|
448
|
+
diagnostic += `\nThe file currently contains:\n${preview}\nYour edits were normalized back to the original content. Ensure your replacement changes actual code, not just formatting.`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return buildEditError(absolutePath, "no-op", diagnostic);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
throwIfAborted(signal);
|
|
455
|
+
|
|
456
|
+
// Syntax-regression validator (warn/block/off)
|
|
457
|
+
const syntaxMode = resolveSyntaxValidateMode({ syntaxValidate: options.syntaxValidate });
|
|
458
|
+
let syntaxWarning: string | undefined;
|
|
459
|
+
if (syntaxMode !== "off") {
|
|
460
|
+
const regression = await validateSyntaxRegression({
|
|
461
|
+
filePath: absolutePath,
|
|
462
|
+
before: originalNormalized,
|
|
463
|
+
after: result,
|
|
464
|
+
});
|
|
465
|
+
if (regression) {
|
|
466
|
+
const lines = regression.errorLines.join(", ");
|
|
467
|
+
const message = `syntax-regression: lines ${lines}`;
|
|
468
|
+
// Task 7 (AC 12): block mode aborts with syntax-regression code; file is left untouched.
|
|
469
|
+
if (syntaxMode === "block") {
|
|
470
|
+
return buildEditError(absolutePath, "syntax-regression", message);
|
|
471
|
+
}
|
|
472
|
+
syntaxWarning = message;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const writeContent = bom + restoreLineEndings(result, originalEnding);
|
|
476
|
+
try {
|
|
477
|
+
await fsWriteFile(absolutePath, writeContent, "utf-8");
|
|
478
|
+
} catch (err: any) {
|
|
479
|
+
const wrapped = wrapWriteError(err, path);
|
|
480
|
+
const code =
|
|
481
|
+
err?.code === "EACCES" || err?.code === "EPERM"
|
|
482
|
+
? "permission-denied"
|
|
483
|
+
: err?.code === "ENOENT"
|
|
484
|
+
? "file-not-found"
|
|
485
|
+
: "fs-error";
|
|
486
|
+
const message =
|
|
487
|
+
code === "fs-error" && err?.message ? `${wrapped.message} — ${err.message}` : wrapped.message;
|
|
488
|
+
return buildEditError(absolutePath, code, message, undefined, code === "fs-error"
|
|
489
|
+
? { fsCode: err?.code, fsMessage: err?.message }
|
|
490
|
+
: undefined);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (input.postEditVerify === true) {
|
|
494
|
+
const postWriteMutationContextHygiene = buildContextHygieneMetadata({
|
|
495
|
+
tool: "edit",
|
|
496
|
+
classification: "mutation",
|
|
497
|
+
resources: [buildFileResource(absolutePath)],
|
|
498
|
+
});
|
|
499
|
+
let verifiedContent: string;
|
|
500
|
+
try {
|
|
501
|
+
const verified = await fsReadFile(absolutePath, "utf-8");
|
|
502
|
+
verifiedContent = verified;
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
const message = `Edit write completed but post-edit verification failed: could not read ${path} after writing.`;
|
|
505
|
+
return buildEditError(absolutePath, "post-edit-verification-read-failed", message, undefined, {
|
|
506
|
+
fsCode: err?.code,
|
|
507
|
+
fsMessage: err?.message,
|
|
508
|
+
},
|
|
509
|
+
postWriteMutationContextHygiene,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (verifiedContent !== writeContent) {
|
|
513
|
+
const message = `Edit write completed but post-edit verification did not confirm the intended content for ${path}. Re-read the file before making follow-up edits.`;
|
|
514
|
+
return buildEditError(absolutePath, "post-edit-verification-mismatch", message, undefined, {
|
|
515
|
+
expectedLength: writeContent.length,
|
|
516
|
+
actualLength: verifiedContent.length,
|
|
517
|
+
},
|
|
518
|
+
postWriteMutationContextHygiene,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const diffResult = generateCompactOrFullDiff(originalNormalized, result);
|
|
524
|
+
const patch = createPatch(path, originalNormalized, result);
|
|
525
|
+
const blockRanges: DiffBlockRange[] = rsProbeResults.map((probe) => ({
|
|
526
|
+
kind: "remove" as const,
|
|
527
|
+
startLine: probe.range.start,
|
|
528
|
+
endLine: probe.range.end,
|
|
529
|
+
}));
|
|
530
|
+
const diffData = buildDiffData({
|
|
531
|
+
path: absolutePath,
|
|
532
|
+
oldContent: originalNormalized,
|
|
533
|
+
newContent: result,
|
|
534
|
+
diff: diffResult.diff,
|
|
535
|
+
...(blockRanges.length ? { blockRanges } : {}),
|
|
536
|
+
});
|
|
537
|
+
const warnings: string[] = [];
|
|
538
|
+
if (anchorResult.warnings?.length) warnings.push(...anchorResult.warnings);
|
|
539
|
+
if (replaceWarnings.length) warnings.push(...replaceWarnings);
|
|
540
|
+
if (replaceSymbolWarnings.length) warnings.push(...replaceSymbolWarnings);
|
|
541
|
+
if (syntaxWarning) warnings.push(syntaxWarning);
|
|
542
|
+
// Semantic classification
|
|
543
|
+
const internalClassification = classifyEdit(originalNormalized, result);
|
|
544
|
+
const difftAvailable = await isDifftAvailable();
|
|
545
|
+
let semanticSummary: SemanticSummary = {
|
|
546
|
+
classification: internalClassification.classification,
|
|
547
|
+
difftasticAvailable: difftAvailable,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
if (difftAvailable) {
|
|
551
|
+
const ext = path.split(".").pop() ?? "txt";
|
|
552
|
+
const difftResult = await runDifftastic(originalNormalized, result, ext);
|
|
553
|
+
if (difftResult) {
|
|
554
|
+
semanticSummary = {
|
|
555
|
+
classification: difftResult.classification,
|
|
556
|
+
difftasticAvailable: true,
|
|
557
|
+
...(difftResult.movedBlocks > 0 ? { movedBlocks: difftResult.movedBlocks } : {}),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const builtOutput = buildEditOutput({
|
|
562
|
+
path: absolutePath,
|
|
563
|
+
displayPath: path,
|
|
564
|
+
diff: diffResult.diff,
|
|
565
|
+
patch,
|
|
566
|
+
diffData,
|
|
567
|
+
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
568
|
+
warnings,
|
|
569
|
+
noopEdits: anchorResult.noopEdits ?? [],
|
|
570
|
+
edits,
|
|
571
|
+
semanticSummary,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const warn = warnings.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
|
|
575
|
+
return {
|
|
576
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
577
|
+
details: {
|
|
578
|
+
diff: diffResult.diff,
|
|
579
|
+
patch: builtOutput.patch,
|
|
580
|
+
diffData,
|
|
581
|
+
firstChangedLine: anchorResult.firstChangedLine ?? diffResult.firstChangedLine,
|
|
582
|
+
readseekValue: builtOutput.readseekValue,
|
|
583
|
+
contextHygiene: builtOutput.contextHygiene,
|
|
584
|
+
} as EditToolDetails & {
|
|
585
|
+
diffData: typeof diffData;
|
|
586
|
+
readseekValue: {
|
|
587
|
+
tool: string;
|
|
588
|
+
ok: boolean;
|
|
589
|
+
path: string;
|
|
590
|
+
summary: string;
|
|
591
|
+
diff: string;
|
|
592
|
+
diffData: typeof diffData;
|
|
593
|
+
firstChangedLine: number | undefined;
|
|
594
|
+
warnings: string[];
|
|
595
|
+
noopEdits: unknown[];
|
|
596
|
+
};
|
|
597
|
+
contextHygiene: ContextHygieneMetadata;
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
} catch (err: any) {
|
|
602
|
+
const code = err?.code;
|
|
603
|
+
if (typeof code === "string") {
|
|
604
|
+
const message = `File not readable: ${path}${err?.message ? ` — ${err.message}` : ""}`;
|
|
605
|
+
return buildEditError(absolutePath, "fs-error", message, undefined, { fsCode: code, fsMessage: err?.message });
|
|
606
|
+
}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
611
|
+
const context: { argsComplete?: boolean; executionStarted?: boolean; lastComponent?: any; cwd?: string; state?: Record<string, any>; invalidate?: () => void; width?: number; expanded?: boolean } = rest[0] ?? {};
|
|
612
|
+
const cwd = context.cwd ?? process.cwd();
|
|
613
|
+
const argsComplete = context.argsComplete ?? false;
|
|
614
|
+
const { path: filePath, suffix } = formatEditCallText(args, argsComplete);
|
|
615
|
+
|
|
616
|
+
let text = theme.fg("toolTitle", theme.bold("edit"));
|
|
617
|
+
if (filePath) text += ` ${linkToolPath(theme.fg("accent", filePath), filePath, cwd)}`;
|
|
618
|
+
else text += ` ${theme.fg("toolOutput", "...")}`;
|
|
619
|
+
const counts = Array.isArray(args?.edits) ? countEditTypes(args.edits) : undefined;
|
|
620
|
+
if (counts && counts.total > 0) {
|
|
621
|
+
text += ` ${theme.fg("dim", `(${counts.total} ${counts.total === 1 ? "edit" : "edits"})`)}`;
|
|
622
|
+
} else if (suffix) {
|
|
623
|
+
text += ` ${theme.fg("dim", suffix)}`;
|
|
624
|
+
}
|
|
625
|
+
text = clampLineToWidth(text, context.width);
|
|
626
|
+
// Once execution has started, the pending preview's only job is done:
|
|
627
|
+
// renderResult will carry the story ("↳ edited +N -M" with the same
|
|
628
|
+
// expandable diff). Keeping the "↳ pending edit" sub-line and its
|
|
629
|
+
// preview alongside the final result is just duplicate noise.
|
|
630
|
+
if (context.executionStarted) {
|
|
631
|
+
const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
|
|
632
|
+
? context.lastComponent
|
|
633
|
+
: new Text("", 0, 0);
|
|
634
|
+
textComponent.setText(text);
|
|
635
|
+
return textComponent;
|
|
636
|
+
}
|
|
637
|
+
const previewKey = buildEditPreviewKey(args ?? {});
|
|
638
|
+
const preview = resolvePendingDiffPreview(
|
|
639
|
+
context,
|
|
640
|
+
EDIT_PENDING_PREVIEW_STATE_KEY,
|
|
641
|
+
previewKey,
|
|
642
|
+
() => buildPendingEditPreviewData(args ?? {}, context.cwd ?? process.cwd()),
|
|
643
|
+
);
|
|
644
|
+
const expanded = !!context.expanded || resolveEditDiffDisplay() === "expanded";
|
|
645
|
+
const preview2 = pendingPreviewLines(text, preview, expanded);
|
|
646
|
+
if (preview2.diffData) {
|
|
647
|
+
const diffComponent = context.lastComponent instanceof DiffPreviewComponent
|
|
648
|
+
? context.lastComponent
|
|
649
|
+
: new DiffPreviewComponent({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
|
|
650
|
+
diffComponent.update({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true });
|
|
651
|
+
return diffComponent;
|
|
652
|
+
}
|
|
653
|
+
const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent))
|
|
654
|
+
? context.lastComponent
|
|
655
|
+
: new Text("", 0, 0);
|
|
656
|
+
textComponent.setText(clampLinesToWidth(preview2.lines, context.width).join("\n"));
|
|
657
|
+
return textComponent;
|
|
658
|
+
},
|
|
659
|
+
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
660
|
+
const context: { isPartial?: boolean; isError?: boolean; expanded?: boolean; lastComponent?: any; width?: number } =
|
|
661
|
+
rest[0] ?? options ?? {};
|
|
662
|
+
const isPartial = context.isPartial ?? (options as any)?.isPartial ?? false;
|
|
663
|
+
const isError = context.isError ?? false;
|
|
664
|
+
|
|
665
|
+
if (isPartial) {
|
|
666
|
+
const width = (context as any).width ?? (options as any)?.width;
|
|
667
|
+
return new Text(clampLinesToWidth([summaryLine("pending edit")], width).join("\n"), 0, 0);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Extract data from result
|
|
671
|
+
const textContent = result.content
|
|
672
|
+
?.filter((c: any) => c.type === "text")
|
|
673
|
+
.map((c: any) => c.text || "")
|
|
674
|
+
.join("\n") ?? "";
|
|
675
|
+
const details = result.details ?? {};
|
|
676
|
+
const diff: string = details.diff ?? "";
|
|
677
|
+
const readseekValue = details.readseekValue as {
|
|
678
|
+
warnings?: string[];
|
|
679
|
+
noopEdits?: unknown[];
|
|
680
|
+
} | undefined;
|
|
681
|
+
const warnings = readseekValue?.warnings ?? [];
|
|
682
|
+
const noopEdits = readseekValue?.noopEdits ?? [];
|
|
683
|
+
const semanticClassification = (readseekValue as any)?.semanticSummary?.classification as string | undefined;
|
|
684
|
+
|
|
685
|
+
const info = formatEditResultText({
|
|
686
|
+
isError: isError || !!result.isError,
|
|
687
|
+
diff,
|
|
688
|
+
warnings,
|
|
689
|
+
noopEdits,
|
|
690
|
+
errorText: textContent,
|
|
691
|
+
semanticClassification: semanticClassification as any,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const expanded = isRendererExpanded(options as any, context as any) || resolveEditDiffDisplay() === "expanded";
|
|
695
|
+
const width = (context as any).width ?? (options as any)?.width;
|
|
696
|
+
const diffData = (details as any).diffData;
|
|
697
|
+
const stats = diffData?.stats ?? { added: 0, removed: 0 };
|
|
698
|
+
let text = "";
|
|
699
|
+
|
|
700
|
+
if (info.noOp) {
|
|
701
|
+
text = summaryLine("no-op");
|
|
702
|
+
if (expanded && info.errorText) text += `\n${theme.fg("error", info.errorText)}`;
|
|
703
|
+
} else if (info.errorText) {
|
|
704
|
+
const firstLine = info.errorText.split("\n")[0] || "Error";
|
|
705
|
+
text = summaryLine(expanded ? info.errorText : firstLine);
|
|
706
|
+
} else {
|
|
707
|
+
const badges: string[] = [`edited +${stats.added} -${stats.removed}`];
|
|
708
|
+
if (info.semanticBadge) badges.push(info.semanticBadge.replace(/^✓\s*/, ""));
|
|
709
|
+
if (info.warningsBadge) badges.push(info.warningsBadge);
|
|
710
|
+
text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded });
|
|
711
|
+
if (expanded && diffData) {
|
|
712
|
+
const diffComponent = context.lastComponent instanceof DiffPreviewComponent
|
|
713
|
+
? context.lastComponent
|
|
714
|
+
: new DiffPreviewComponent({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
|
|
715
|
+
diffComponent.update({ prefixLines: text.split("\n"), diffData, theme, expanded: true });
|
|
716
|
+
return diffComponent;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
720
|
+
},
|
|
721
|
+
} satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
|
|
722
|
+
|
|
723
|
+
pi.registerTool(tool);
|
|
724
|
+
return tool;
|
|
725
|
+
}
|