import { withFileMutationQueue, type ExtensionAPI, type EditToolDetails, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { Static } from "typebox"; import { withLegacyObjectOrder } from "./typebox-schema-order.js"; import { defineToolPromptMetadata } from "./tool-prompt-metadata.js"; import { readFile as fsReadFile } from "fs/promises"; import { createPatch } from "diff"; import { detectLineEnding, findPhysicalTextSpans, generateCompactOrFullDiff, normalizeToLF, replacePhysicalText, replaceText, restoreLineEndings, stripBom } from "./edit-diff.js"; import { PhysicalLineBuffer } from "./physical-lines.js"; import { HashlineMismatchError, HashlineOverlapError, applyHashlineEdits, computeLineHash, ensureHashInit, parseLineRef, type HashlineEditItem, escapeControlCharsForDisplay, } from "./hashline.js"; import { findCorruptedRetype } from "./retype-guard.js"; import { formatStaleRows, overwrittenLines, type ServedLines, type ShiftSummary } from "./served-lines.js"; import { remapShiftedAnchors } from "./shifted-anchors.js"; import type { PtcLine } from "./ptc-value.js"; import { resolveToCwd } from "./path-utils.js"; import { resolveMutationTargetPath, writeFileAtomically } from "./fs-write.js"; import { throwIfAborted } from "./runtime.js"; import { buildEditOutput } from "./edit-output.js"; import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js"; import type { SemanticSummary } from "./ptc-value.js"; import { buildPtcError } from "./ptc-value.js"; import { Text } from "@earendil-works/pi-tui"; import { countEditTypes, formatEditCallText, formatEditResultText } from "./edit-render-helpers.js"; import { validateSyntaxRegression } from "./edit-syntax-validate.js"; import { resolveSyntaxValidateMode, type SyntaxValidateOptions } from "./syntax-validate-mode.js"; import { replaceSymbol, type ReplaceSymbolResult } from "./replace-symbol.js"; import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPreview, type PendingDiffPreviewResult } from "./pending-diff-preview.js"; import { buildDiffData, type DiffBlockRange } from "./diff-data.js"; import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, summaryLine } from "./tui-render-utils.js"; import { DiffPreviewComponent } from "./tui-diff-component.js"; import { buildContextHygieneMetadata, buildFileResource, type ContextHygieneMetadata } from "./context-hygiene.js"; import { resolveEditDiffDisplay } from "./hashline-settings.js"; import { packageFileUrl } from "./package-root.js"; import { looksLikeBinary } from "./binary-detect.js"; import { buildRequiredNullParameterError, normalizeToolParameters, } from "./normalize-tool-params.js"; const EDIT_PENDING_PREVIEW_STATE_KEY = "hashline-edit-pending-preview"; function pendingPreviewLines(summary: string, preview: PendingDiffPreviewResult | undefined, expanded: boolean): { lines: string[]; diffData?: ReturnType; headerLabel?: string } { if (!expanded || !preview || preview.type !== "ok") return { lines: summary.split("\n") }; const diffData = buildDiffData({ path: preview.data.filePath, oldContent: preview.data.previousContent, newContent: preview.data.nextContent, diff: preview.data.diff, }); const headerLine = summaryLine(preview.data.headerLabel, { hidden: false }); return { lines: [summary, headerLine], diffData, headerLabel: preview.data.headerLabel }; } export function wrapWriteError(err: any, path: string): Error { const code = err?.code; if (code === "EACCES" || code === "EPERM") { return new Error(`Permission denied: ${path}`); } return new Error(`Failed to write file: ${path}`); } export function isBinaryBuffer(buf: Buffer): boolean { return looksLikeBinary(buf); } // ─── Schema ───────────────────────────────────────────────────────────── const hashlineEditItemSchema = Type.Union([ withLegacyObjectOrder(Type.Object({ set_line: Type.Object({ anchor: Type.String({ description: "Fresh LINE:HASH anchor" }), new_text: Type.String(), literal: Type.Optional(Type.Boolean({ description: "Write new_text exactly; skip anchor-prefix cleanup" })), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ replace_lines: Type.Object({ start_anchor: Type.String({ description: "Fresh LINE:HASH start anchor" }), end_anchor: Type.String({ description: "Fresh LINE:HASH end anchor" }), new_text: Type.String(), literal: Type.Optional(Type.Boolean({ description: "Write new_text exactly; skip anchor-prefix cleanup" })), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ insert_after: Type.Object({ anchor: Type.String({ description: "Fresh LINE:HASH anchor" }), new_text: Type.String(), text: Type.Optional(Type.String()), literal: Type.Optional(Type.Boolean({ description: "Write new_text exactly; skip anchor-prefix cleanup" })), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ replace: Type.Object({ old_text: Type.String({ description: "Non-empty exact target text" }), new_text: Type.String(), all: Type.Optional(Type.Boolean()), fuzzy: Type.Optional(Type.Boolean()), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ replace_symbol: Type.Object({ symbol: Type.String(), new_body: Type.String({ description: "Non-blank complete symbol body" }), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ copy_lines: Type.Object({ start_anchor: Type.String({ description: "First source line (LINE:HASH)" }), end_anchor: Type.String({ description: "Last source line (LINE:HASH)" }), after_anchor: Type.String({ description: "Insert the copy after this line of path (LINE:HASH)" }), from_path: Type.Optional(Type.String({ description: "Source file to copy from; default path" })), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object({ move_lines: Type.Object({ start_anchor: Type.String({ description: "First line to move (LINE:HASH)" }), end_anchor: Type.String({ description: "Last line to move (LINE:HASH)" }), after_anchor: Type.String({ description: "Move the lines after this line (LINE:HASH)" }), from_path: Type.Optional(Type.String({ description: "Move from this file into path; default path" })), }), }, { additionalProperties: true })), withLegacyObjectOrder(Type.Object( { old_text: Type.String(), new_text: Type.String() }, { additionalProperties: true, description: "Do not use — Wrap as { replace: {old_text, new_text} }." }, )), ], { description: "Overlaps reject; set_line last wins; safe insert_after ok" }); const hashlineEditSchema = withLegacyObjectOrder(Type.Object( { path: Type.String({ description: "Existing file path; requires fresh session anchors" }), edits: Type.Optional(Type.Array(hashlineEditItemSchema, { description: "Non-empty; each item has exactly one supported variant", })), postEditVerify: Type.Optional(Type.Boolean({ description: "Verify persisted content after write", })), }, { additionalProperties: true }, )); type HashlineParams = Static; const EDIT_PROMPT_METADATA = defineToolPromptMetadata({ promptUrl: packageFileUrl("prompts", "edit.md"), promptSnippet: "Edit files using hash-verified anchors from read/grep/ast_search/write", promptGuidelines: [ "Use edit for changes to existing files; read or search first and copy fresh LINE:HASH anchors.", "Prefer edit anchored set_line, replace_lines, and insert_after over shell rewrites.", "Use edit replace only when anchored edits are impractical.", ], }); function buildEditError( path: string, code: string, message: string, hint?: string, errorDetails?: Record, contextHygiene?: ContextHygieneMetadata, ): { content: [{ type: "text"; text: string }]; isError: true; details: EditToolDetails & { ptcValue: any; contextHygiene?: ContextHygieneMetadata }; } { return { content: [{ type: "text", text: message }], isError: true, details: { diff: "", patch: "", firstChangedLine: undefined, ptcValue: { tool: "edit", ok: false, path, error: buildPtcError(code, message, hint, errorDetails), }, ...(contextHygiene ? { contextHygiene } : {}), } as EditToolDetails & { ptcValue: any; contextHygiene?: ContextHygieneMetadata }, }; } type EditErrorResult = ReturnType; type EditItem = NonNullable[number]; type ReplaceEditItem = { replace: { old_text: string; new_text: string; all?: boolean; fuzzy?: boolean } }; type ReplaceSymbolEditItem = { replace_symbol: { symbol: string; new_body: string } }; type EditPhaseResult = T | EditErrorResult; function isEditErrorResult(value: unknown): value is EditErrorResult { return !!value && typeof value === "object" && (value as { isError?: unknown }).isError === true; } interface ValidatedEdits { edits: EditItem[]; anchorEdits: HashlineEditItem[]; replaceEdits: ReplaceEditItem[]; replaceSymbolEdits: ReplaceSymbolEditItem[]; legacyNormalizationWarning?: string; } interface LoadedEditSource { bom: string; originalEnding: ReturnType; originalNormalized: string; originalContent: string; } function validateEdits(input: { parsed: HashlineParams; rawInput: Record; absolutePath: string; signal?: AbortSignal; }): EditPhaseResult { const { parsed, rawInput, absolutePath, signal } = input; const legacyOldText = typeof rawInput.oldText === "string" ? rawInput.oldText : typeof rawInput.old_text === "string" ? rawInput.old_text : undefined; const legacyNewText = typeof rawInput.newText === "string" ? rawInput.newText : typeof rawInput.new_text === "string" ? rawInput.new_text : undefined; const hasLegacyInput = legacyOldText !== undefined || legacyNewText !== undefined; if (typeof (parsed as { edits?: unknown }).edits === "string") { try { const reparsed = JSON.parse((parsed as { edits?: unknown }).edits as string); if (Array.isArray(reparsed)) { (parsed as { edits?: unknown }).edits = reparsed; (rawInput as { edits?: unknown }).edits = reparsed; } } catch { // Fall through so the existing validation path reports the error. } } const hasEditsInput = Array.isArray(parsed.edits); let edits: EditItem[] = Array.isArray(parsed.edits) ? parsed.edits : []; let legacyNormalizationWarning: string | undefined; if (!hasEditsInput && hasLegacyInput) { if (legacyOldText === undefined || legacyNewText === undefined) { return buildEditError( absolutePath, "invalid-edit-variant", "Legacy edit input requires both oldText/newText (or old_text/new_text) when 'edits' is omitted.", ); } edits = [{ replace: { old_text: legacyOldText, new_text: legacyNewText, ...(typeof rawInput.all === "boolean" ? { all: rawInput.all } : {}), }, }]; legacyNormalizationWarning = "Legacy top-level oldText/newText input was normalized to edits[0].replace. Prefer the edits[] format."; } if (!edits.length) { return buildEditError(absolutePath, "invalid-edit-variant", "No edits provided."); } for (let i = 0; i < edits.length; i++) { throwIfAborted(signal); const edit = edits[i] as Record; if (("old_text" in edit || "new_text" in edit) && !("replace" in edit)) { return buildEditError( absolutePath, "invalid-edit-variant", `edits[${i}] has top-level 'old_text'/'new_text'. Use {replace: {old_text, new_text}} or {set_line}, {replace_lines}, {insert_after}.`, ); } if ("diff" in edit) { return buildEditError( absolutePath, "invalid-edit-variant", `edits[${i}] contains 'diff' from patch mode. Hashline edit expects one of: {set_line}, {replace_lines}, {insert_after}, {replace}.`, ); } const variantCount = Number("set_line" in edit) + Number("replace_lines" in edit) + Number("insert_after" in edit) + Number("copy_lines" in edit) + Number("move_lines" in edit) + Number("replace" in edit) + Number("replace_symbol" in edit); if (variantCount !== 1) { return buildEditError( absolutePath, "invalid-edit-variant", `edits[${i}] must contain exactly one of: 'set_line', 'replace_lines', 'insert_after', 'copy_lines', 'move_lines', 'replace', 'replace_symbol'. Got: [${Object.keys(edit).join(", ")}].`, ); } } const anchorEdits = edits.filter( (edit): edit is Extract => "set_line" in edit || "replace_lines" in edit || "insert_after" in edit || "copy_lines" in edit || "move_lines" in edit, ) as HashlineEditItem[]; const replaceEdits = edits.filter( (edit): edit is ReplaceEditItem => "replace" in edit, ); const replaceSymbolEdits = edits.filter( (edit): edit is ReplaceSymbolEditItem => "replace_symbol" in edit, ); for (const edit of replaceSymbolEdits) { if (!edit.replace_symbol.new_body.trim()) { return buildEditError( absolutePath, "invalid-edit-variant", "replace_symbol.new_body must not be empty or whitespace-only.", ); } } return { edits, anchorEdits, replaceEdits, replaceSymbolEdits, legacyNormalizationWarning }; } async function loadEditSource(input: { absolutePath: string; displayPath: string; signal?: AbortSignal; }): Promise> { const { absolutePath, displayPath, signal } = input; let rawBuffer: Buffer; try { rawBuffer = await fsReadFile(absolutePath); } catch (err: any) { const code = err?.code; let errCode: string; let message: string; let hint: string | undefined; let errorDetails: { fsCode?: string; fsMessage?: string } | undefined; if (code === "EISDIR") { errCode = "path-is-directory"; message = `Path is a directory: ${displayPath}`; hint = `Use ls(${JSON.stringify(displayPath)}) to inspect directories.`; } else if (code === "ENOENT") { errCode = "file-not-found"; message = `File not found: ${displayPath}`; } else if (code === "EACCES" || code === "EPERM") { errCode = "permission-denied"; message = `Permission denied: ${displayPath}`; } else { errCode = "fs-error"; message = `File not readable: ${displayPath}${err?.message ? ` — ${err.message}` : ""}`; errorDetails = { fsCode: code, fsMessage: err?.message }; } return buildEditError(absolutePath, errCode, message, hint, errorDetails); } if (isBinaryBuffer(rawBuffer)) { return buildEditError(absolutePath, "binary-file", `Cannot edit binary file: ${displayPath}`); } throwIfAborted(signal); const raw = rawBuffer.toString("utf-8"); const { bom, text: content } = stripBom(raw); return { bom, originalEnding: detectLineEnding(content), originalNormalized: normalizeToLF(content), originalContent: content, }; } type ReplaceSymbolProbe = Extract; async function resolveReplaceSymbols(input: { absolutePath: string; originalNormalized: string; replaceSymbolEdits: ReplaceSymbolEditItem[]; }): Promise> { const probes: ReplaceSymbolProbe[] = []; for (const edit of input.replaceSymbolEdits) { const probe = await replaceSymbol({ filePath: input.absolutePath, content: input.originalNormalized, symbol: edit.replace_symbol.symbol, newBody: normalizeToLF(edit.replace_symbol.new_body), }); if (probe.type !== "ok") { const message = probe.type === "not-found" ? `${probe.message}\n${describeLineTargetForSymbolSlip(input.originalNormalized, edit.replace_symbol.symbol)}` : probe.message; return buildEditError(input.absolutePath, "invalid-edit-variant", message); } probes.push(probe); } return probes; } function validateReplaceSymbolOverlaps(input: { absolutePath: string; probes: ReplaceSymbolProbe[]; anchorEdits: HashlineEditItem[]; }): EditErrorResult | undefined { const ranges = input.probes.map((probe) => probe.range); const sortedRanges = [...ranges].sort((a, b) => a.start - b.start || a.end - b.end); for (let i = 1; i < sortedRanges.length; i++) { const previous = sortedRanges[i - 1]; const current = sortedRanges[i]; if (current.start <= previous.end) { return buildEditError( input.absolutePath, "invalid-edit-variant", `replace_symbol ranges overlap or duplicate (lines ${previous.start}-${previous.end} and ${current.start}-${current.end}).`, ); } } if (ranges.length === 0) return undefined; for (const edit of input.anchorEdits) { if ("replace_lines" in edit) { let startLine: number | undefined; let endLine: number | undefined; try { startLine = parseLineRef(edit.replace_lines.start_anchor).line; endLine = parseLineRef(edit.replace_lines.end_anchor).line; } catch { // Normal anchored-edit validation reports malformed anchors later. } if (startLine !== undefined && endLine !== undefined) { const low = Math.min(startLine, endLine); const high = Math.max(startLine, endLine); for (const range of ranges) { if (low <= range.end && high >= range.start) { return buildEditError( input.absolutePath, "invalid-edit-variant", `replace_lines range ${low}-${high} overlaps a replace_symbol range (lines ${range.start}-${range.end}).`, ); } } } } const refs: string[] = []; if ("set_line" in edit) refs.push(edit.set_line.anchor); else if ("replace_lines" in edit) refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor); else if ("insert_after" in edit) refs.push(edit.insert_after.anchor); for (const ref of refs) { let line: number | undefined; try { line = parseLineRef(ref).line; } catch { continue; } for (const range of ranges) { if (line >= range.start && line <= range.end) { return buildEditError( input.absolutePath, "invalid-edit-variant", `Anchor at line ${line} falls inside a replace_symbol range (lines ${range.start}-${range.end}).`, ); } } } } return undefined; } function applyResolvedReplaceSymbols( originalContent: string, probes: ReplaceSymbolProbe[], physical = false, ): { content: string; warnings: string[] } { if (!probes.length) return { content: originalContent, warnings: [] }; const buffer = physical ? new PhysicalLineBuffer(originalContent) : undefined; const lines = originalContent.split("\n"); for (const probe of [...probes].sort((a, b) => b.range.start - a.range.start)) { const index = probe.range.start - 1; const count = probe.range.end - probe.range.start + 1; const replacement = normalizeToLF(probe.replacement).split("\n"); if (buffer) buffer.splice(index, count, replacement); else lines.splice(index, count, ...replacement); } return { content: buffer ? buffer.toString() : lines.join("\n"), warnings: probes.flatMap(probe => probe.warnings) }; } type AnchorEditResult = ReturnType; /** The `from_path` of a copy or move, trimmed; undefined when the edit reads only the edited file. */ function crossFilePath(edit: HashlineEditItem): string | undefined { const fromPath = "copy_lines" in edit ? edit.copy_lines.from_path : "move_lines" in edit ? edit.move_lines.from_path : undefined; return fromPath?.trim() || undefined; } interface SourceRemoval { absolutePath: string; displayPath: string; originalNormalized: string; result: string; physicalResult?: string; bom: string; originalEnding: ReturnType; ranges: string[]; } /** * For `move_lines` with a `from_path` in another file: verify and compute the source file with the * moved range removed, before anything is written. The source needs fresh anchors like any edit. */ async function planSourceRemovals(input: { anchorEdits: HashlineEditItem[]; absolutePath: string; cwd: string; options: EditToolOptions; signal?: AbortSignal; }): Promise> { const bySource = new Map(); for (const edit of input.anchorEdits) { if (!("move_lines" in edit)) continue; const fromPath = crossFilePath(edit); if (fromPath === undefined) continue; const sourcePath = resolveToCwd(fromPath.replace(/^@/, ""), input.cwd); if (sourcePath === input.absolutePath) continue; const entry = bySource.get(sourcePath) ?? { displayPath: fromPath, edits: [], ranges: [] }; entry.edits.push({ replace_lines: { start_anchor: edit.move_lines.start_anchor, end_anchor: edit.move_lines.end_anchor, new_text: "" } }); entry.ranges.push(`${edit.move_lines.start_anchor}..${edit.move_lines.end_anchor}`); bySource.set(sourcePath, entry); } const removals: SourceRemoval[] = []; for (const [sourcePath, entry] of bySource) { const notRead = requireReadForAnchors(input.options, sourcePath, entry.displayPath, true); if (notRead) return notRead; const loaded = await loadEditSource({ absolutePath: sourcePath, displayPath: entry.displayPath, signal: input.signal }); if (isEditErrorResult(loaded)) return loaded; const applied = await applyAnchorEdits({ absolutePath: sourcePath, content: loaded.originalContent, anchorEdits: entry.edits, cwd: input.cwd, signal: input.signal, preserveLineEndings: true }); if (isEditErrorResult(applied)) return recordErrorFeedback(input.options, sourcePath, applied); const stale = rejectStaleOverwrites(input.options.served, sourcePath, loaded.originalNormalized, normalizeToLF(applied.content), loaded.originalContent, applied.content); if (stale) { input.options.onFileAnchored?.(sourcePath); return stale; } removals.push({ absolutePath: sourcePath, displayPath: entry.displayPath, ...loaded, result: normalizeToLF(applied.content), physicalResult: applied.content, ranges: entry.ranges }); } return removals; } /** Write planned source removals after the target was written. A failure names the half-done state. */ async function commitSourceRemovals(removals: SourceRemoval[], options: EditToolOptions, targetDisplayPath: string): Promise> { const notes: string[] = []; for (const removal of removals) { const written = await withFileMutationQueue(await resolveMutationTargetPath(removal.absolutePath), () => finalizeWrite({ ...removal, postEditVerify: false }), ); if (isEditErrorResult(written)) { const text = `${written.content[0].text}\n${targetDisplayPath} was already written with the moved lines; they are still in ${removal.displayPath} too. Delete them there to finish the move.`; return { ...written, content: [{ type: "text", text }] }; } options.served?.remapAfterWrite(removal.absolutePath, removal.originalNormalized.split("\n"), removal.result.split("\n")); notes.push(`Moved lines ${removal.ranges.join(", ")} out of ${removal.displayPath}; read it again for fresh anchors there.`); } return notes; } /** Read the other files `copy_lines.from_path` names, LF-normalized, keyed by the given string. */ async function loadCopySources( anchorEdits: HashlineEditItem[], absolutePath: string, cwd: string, signal?: AbortSignal, physical = false, ): Promise>> { const sources = new Map(); for (const edit of anchorEdits) { const fromPath = crossFilePath(edit); if (fromPath === undefined) continue; if (!fromPath || sources.has(fromPath)) continue; const sourcePath = resolveToCwd(fromPath.replace(/^@/, ""), cwd); if (sourcePath === absolutePath) continue; const loaded = await loadEditSource({ absolutePath: sourcePath, displayPath: fromPath, signal }); if (isEditErrorResult(loaded)) return loaded; sources.set(fromPath, physical ? loaded.originalContent : loaded.originalNormalized); } return sources; } async function applyAnchorEdits(input: { absolutePath: string; content: string; anchorEdits: HashlineEditItem[]; cwd: string; signal?: AbortSignal; preserveLineEndings?: boolean; }): Promise> { const sources = await loadCopySources(input.anchorEdits, input.absolutePath, input.cwd, input.signal, input.preserveLineEndings); if (isEditErrorResult(sources)) return sources; // A from_path naming the edited file itself is a copy or move within the file. const anchorEdits = input.anchorEdits.map((edit) => { const fromPath = crossFilePath(edit); if (fromPath === undefined || sources.has(fromPath)) return edit; if ("copy_lines" in edit) return { copy_lines: { ...edit.copy_lines, from_path: undefined } }; if ("move_lines" in edit) return { move_lines: { ...edit.move_lines, from_path: undefined } }; return edit; }); try { const physical = input.preserveLineEndings ? new PhysicalLineBuffer(input.content) : undefined; // Original snapshots supply copied internal boundaries even after earlier target splices. const original = physical ? new PhysicalLineBuffer(input.content) : undefined; const sourceBuffers = new Map([...sources].map(([path, text]) => [path, new PhysicalLineBuffer(text)])); const logicalSources = input.preserveLineEndings ? new Map([...sources].map(([path, text]) => [path, normalizeToLF(text)])) : sources; const applied = applyHashlineEdits(input.preserveLineEndings ? normalizeToLF(input.content) : input.content, anchorEdits, input.signal, { sources: logicalSources, onSplice: physical ? (index, count, lines, copy) => { const source = copy ? copy.fromPath ? sourceBuffers.get(copy.fromPath) : original : undefined; const endings = source && copy ? source.endings.slice(copy.startLine - 1, copy.startLine - 1 + lines.length) : undefined; physical.splice(index, count, lines, endings); } : undefined, }); return { ...applied, content: physical ? physical.toString() : applied.content }; } catch (err) { if (err instanceof HashlineMismatchError) { return buildEditError(input.absolutePath, "hash-mismatch", err.message, undefined, { updatedAnchors: err.updatedAnchors, }); } if (err instanceof HashlineOverlapError) { return buildEditError(input.absolutePath, "overlapping-edit", err.message); } throw err; } } /** A row as tools display it, `LINE:HASH|content` or the bare `HASH|content` slip. */ const SHOWN_ROW_RE = /^(?:\d+:)?([0-9a-f]{3})\|(.*)$/; const SHOWN_TERMINATOR_ROW_RE = /^(?:\d+:)?([0-9a-f]{3})\|?$/; function splitTextRows(text: string): string[] { return normalizeToLF(text).replace(/\n$/, "").split("\n"); } /** * Models often paste displayed rows into `replace.old_text`. When every row carries a prefix whose * hash matches its own content, the rows are a verified copy of shown lines: return their content. */ function stripVerifiedRowPrefixes(text: string): string[] | undefined { const rows = splitTextRows(text); const emptyHash = computeLineHash(0, ""); const last = rows[rows.length - 1]; const terminator = last !== undefined ? SHOWN_TERMINATOR_ROW_RE.exec(last) : null; if (rows.length > 1 && terminator && terminator[1] === emptyHash) rows.pop(); const out: string[] = []; for (const row of rows) { const match = SHOWN_ROW_RE.exec(row); if (!match || computeLineHash(0, match[2]) !== match[1]) return undefined; out.push(match[2]); } return out.length ? out : undefined; } /** Replacement rows for a prefixed `old_text`: drop any pasted row prefixes, keep the rest literal. */ function stripPastedRowPrefixes(text: string): string[] { if (text === "") return []; return splitTextRows(text).map((row) => SHOWN_ROW_RE.exec(row)?.[2] ?? row); } /** Replace whole lines equal to `oldRows` (exact, line-aligned). */ function replaceLineBlock(content: string, oldRows: string[], newRows: string[], all: boolean, physical = false): { content: string; count: number } { const buffer = physical ? new PhysicalLineBuffer(content) : undefined; const lines = buffer ? buffer.lines : content.split("\n"); const starts: number[] = []; for (let start = 0; start + oldRows.length <= lines.length; start++) { if (oldRows.every((row, offset) => lines[start + offset] === row)) { starts.push(start); start += oldRows.length - 1; } } if (!starts.length || (!all && starts.length > 1)) return { content, count: starts.length > 1 ? -starts.length : 0 }; for (const start of [...starts].reverse()) { if (buffer) buffer.splice(start, oldRows.length, newRows); else lines.splice(start, oldRows.length, ...newRows); } return { content: buffer ? buffer.toString() : lines.join("\n"), count: starts.length }; } function lineSimilarity(needle: string, line: string): number { const a = needle.trim(); const b = line.trim(); if (!a || !b) return 0; if (b.includes(a) || a.includes(b)) return 1; const tokens = (s: string) => new Set(s.split(/[^\p{L}\p{N}_]+/u).filter(Boolean)); const ta = tokens(a); const tb = tokens(b); if (!ta.size || !tb.size) return 0; let overlap = 0; for (const token of ta) if (tb.has(token)) overlap++; return overlap / Math.max(ta.size, tb.size); } /** Current rows most like `needle`, as fresh `LINE:HASH|content` anchors. */ function closestRows(content: string, needle: string, max = 4): PtcLine[] { const lines = content.split("\n"); return lines .map((raw, index) => ({ raw, line: index + 1, score: lineSimilarity(needle, raw) })) .filter((candidate) => candidate.score >= 0.5) .sort((a, b) => b.score - a.score || a.line - b.line) .slice(0, max) .sort((a, b) => a.line - b.line) .map(({ raw, line }) => { const hash = computeLineHash(line, raw); const display = escapeControlCharsForDisplay(raw); return { line, hash, anchor: `${line}:${hash}`, raw, display }; }); } function formatRows(rows: readonly PtcLine[]): string { return rows.map((row) => ` ${row.anchor}|${row.display}`).join("\n"); } /** * An exact `old_text` that occurs more than once would silently edit the first occurrence. Refuse * and show the line each occurrence starts on, so the model can add context or use an anchor. */ function describeAmbiguousReplace(content: string, oldText: string, displayPath: string, physical = false): { message: string; rows: PtcLine[] } | undefined { const starts: number[] = []; if (physical) { for (const span of findPhysicalTextSpans(content, oldText)) starts.push(span.index); } else { for (let index = content.indexOf(oldText); index !== -1; index = content.indexOf(oldText, index + oldText.length)) starts.push(index); } if (starts.length < 2) return undefined; const lines = normalizeToLF(content).split("\n"); const lineNumbers: number[] = []; let cursor = 0; let line = 1; for (const start of starts) { while (cursor < start) { const char = content[cursor++]; if (char === "\n") line++; else if (char === "\r") { line++; if (content[cursor] === "\n") cursor++; } } if (lineNumbers[lineNumbers.length - 1] !== line) lineNumbers.push(line); } const rows: PtcLine[] = lineNumbers.slice(0, 10).map(line => { const raw = lines[line - 1] ?? ""; const hash = computeLineHash(line, raw); return { line, hash, anchor: `${line}:${hash}`, raw, display: escapeControlCharsForDisplay(raw) }; }); const more = lineNumbers.length > rows.length ? `\n ... and ${lineNumbers.length - rows.length} more lines` : ""; return { message: [ `replace.old_text occurs ${starts.length} times in ${displayPath}; nothing was written. Matches start on:`, formatRows(rows) + more, "Add surrounding text to old_text so it matches once, set all: true to replace every occurrence, or use set_line with the anchor of the line you mean.", ].join("\n"), rows, }; } /** Guidance when replace_symbol names a line, an anchor, or a file instead of a declaration. */ function describeLineTargetForSymbolSlip(content: string, symbol: string): string { const row = SHOWN_ROW_RE.exec(symbol.trim().split("\n")[0] ?? ""); const needle = row ? row[2] : symbol.replace(/^\d+:[0-9a-f]{3}\|?/, ""); const rows = needle.trim() ? closestRows(content, needle, 3) : []; const example = rows[0] ? `{"set_line": {"anchor": "${rows[0].anchor}", "new_text": "..."}}` : `{"set_line": {"anchor": "LINE:HASH", "new_text": "..."}}`; return [ "replace_symbol only replaces a named declaration (function, class, method). To change lines, use set_line, replace_lines, or insert_after with LINE:HASH anchors from read, for example " + example + ".", ...(rows.length ? ["Matching lines:", formatRows(rows)] : []), ].join("\n"); } function applyReplaceEdits(input: { absolutePath: string; displayPath: string; content: string; replaceEdits: ReplaceEditItem[]; signal?: AbortSignal; preserveLineEndings?: boolean; }): EditPhaseResult<{ content: string; warnings: string[] }> { let content = input.content; const warnings: string[] = []; for (const edit of input.replaceEdits) { throwIfAborted(input.signal); if (!edit.replace.old_text.length) { return buildEditError(input.absolutePath, "invalid-edit-variant", "replace.old_text must not be empty."); } // Physical matching prefers byte-exact spans, then CRLF/LF-equivalent spans. const oldText = input.preserveLineEndings ? edit.replace.old_text : edit.replace.old_text.replace(/\r\n/g, "\n"); const all = edit.replace.all ?? false; if (!all) { const ambiguous = describeAmbiguousReplace(content, oldText, input.displayPath, input.preserveLineEndings); if (ambiguous) { return buildEditError(input.absolutePath, "ambiguous-match", ambiguous.message, undefined, { updatedAnchors: ambiguous.rows }); } } const replacement = (input.preserveLineEndings ? replacePhysicalText : replaceText)(content, oldText, edit.replace.new_text, { all, fuzzy: edit.replace.fuzzy ?? false, }); if (replacement.count) { if (replacement.usedFuzzyMatch) { warnings.push( "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.", ); } content = replacement.content; continue; } // old_text pasted as displayed rows (`2:467|bbb` or `467|bbb`): every prefix hash verifies // its own row, so match those rows as whole lines. A row that changed since it was shown // no longer exists and the edit is refused below instead of matching a substring. const shownRows = stripVerifiedRowPrefixes(oldText); if (shownRows) { const block = replaceLineBlock(content, shownRows, stripPastedRowPrefixes(edit.replace.new_text), all, input.preserveLineEndings); if (block.count > 0) { warnings.push( "replace.old_text contained LINE:HASH| row prefixes; matched those rows as whole lines. Next time, use set_line/replace_lines with the anchors, or pass old_text without prefixes.", ); content = block.content; continue; } if (block.count < 0) { return buildEditError( input.absolutePath, "text-not-found", `replace.old_text matches ${-block.count} places in ${input.displayPath}. Use set_line or replace_lines with the LINE:HASH anchor of the one you mean.`, ); } } const needle = (shownRows ?? splitTextRows(oldText)).find((row) => row.trim().length > 0) ?? oldText; const rows = closestRows(normalizeToLF(content), needle); const lines = [`Could not find exact text to replace in ${input.displayPath}.`]; if (rows.length) { lines.push("Closest current lines:", formatRows(rows)); lines.push( `To change a line, use set_line with its anchor, for example {"set_line": {"anchor": "${rows[0].anchor}", "new_text": "..."}}.`, ); } else { lines.push("No similar line exists; the text may have changed. Re-read the file."); } lines.push("old_text must match the file exactly and must not include LINE:HASH| prefixes."); const hint = "Re-read the file if unsure and prefer set_line/replace_lines/insert_after for hash-verified edits. " + "The replace variant is exact-only by default because fuzzy fallback is unverified."; return buildEditError(input.absolutePath, "text-not-found", lines.join("\n"), hint, rows.length ? { updatedAnchors: rows } : undefined); } return { content, warnings }; } /** * An edit whose result equals the current file is reported as a successful no-op, not an error: * the file already has the requested content, so retrying cannot help. Nothing is written. */ function buildNoopResult(path: string, message: string, noopEdits: unknown[]) { return { content: [{ type: "text" as const, text: message }], details: { diff: "", patch: "", firstChangedLine: undefined, ptcValue: { tool: "edit", ok: true, noop: true, path, summary: message, diff: "", firstChangedLine: undefined, warnings: [], noopEdits, }, } as EditToolDetails & { ptcValue: any }, }; } function detectNoop(input: { absolutePath: string; displayPath: string; originalNormalized: string; result: string; edits: EditItem[]; anchorResult: AnchorEditResult; }): ReturnType | undefined { if (input.originalNormalized !== input.result) return undefined; let diagnostic = `No changes made to ${input.displayPath}: the file already has this content. Nothing was written.`; if (input.anchorResult.noopEdits?.length) { diagnostic += "\n" + input.anchorResult.noopEdits .map( (edit) => `Edit ${edit.editIndex}: replacement for ${edit.loc} is identical to current content:\n ${edit.loc}| ${escapeControlCharsForDisplay(edit.currentContent)}`, ) .join("\n"); } else { const lines = input.result.split("\n"); const targetLines: string[] = []; for (const edit of input.edits) { const refs: string[] = []; if ("set_line" in edit) refs.push(edit.set_line.anchor); else if ("replace_lines" in edit) refs.push(edit.replace_lines.start_anchor, edit.replace_lines.end_anchor); else if ("insert_after" in edit) refs.push(edit.insert_after.anchor); for (const ref of refs) { try { const parsed = parseLineRef(ref); if (parsed.line >= 1 && parsed.line <= lines.length) { const lineContent = lines[parsed.line - 1]; const hash = computeLineHash(parsed.line, lineContent); targetLines.push(`${parsed.line}:${hash}|${escapeControlCharsForDisplay(lineContent)}`); } } catch { // Skip malformed refs; anchored validation already handles them. } } } if (targetLines.length > 0) { const preview = [...new Set(targetLines)].slice(0, 5).join("\n"); 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.`; } } return buildNoopResult(input.absolutePath, diagnostic, input.anchorResult.noopEdits ?? []); } /** * Anchored and symbol edits need fresh anchors from this session. Text `replace` does not: an * exact, unique match already proves the model knows the current content, and ambiguous or * missing matches are refused before anything is written. */ function requireReadForAnchors( options: EditToolOptions, absolutePath: string, rawPath: string, usesAnchors: boolean, ): EditErrorResult | undefined { if (!usesAnchors || !options.wasReadInSession || options.wasReadInSession(absolutePath)) return undefined; const readHint = `Call read(${JSON.stringify(rawPath)}) first, or use grep, ast_search, or write to produce fresh anchors for this file.`; return buildEditError( absolutePath, "file-not-read", [ `You must get fresh anchors for ${absolutePath} before editing it.`, readHint, "edit requires fresh LINE:HASH anchors from read, grep, ast_search, or write so the hashes match the current file contents.", "A text replace with an exact, unique old_text does not need a read.", ].join(" "), readHint, ); } /** Rows shown in error feedback count as seen: the retry needs no re-read. */ function recordErrorFeedback(options: EditToolOptions, absolutePath: string, error: T): T { const shown = (error.details.ptcValue as any)?.error?.details?.updatedAnchors as PtcLine[] | undefined; if (shown?.length) { options.served?.record(absolutePath, shown); options.onFileAnchored?.(absolutePath); } return error; } /** Tell the caller which of its anchors this edit moved; they are stale from here on. */ function describeOwnShift(shift: ShiftSummary | undefined): string[] { if (!shift) return []; const by = shift.delta > 0 ? `+${shift.delta}` : String(shift.delta); const anchors = shift.count === 1 ? "1 earlier anchor there is" : `${shift.count} earlier anchors there are`; return [`Line numbers shifted by ${by} from old line ${shift.fromLine}; ${anchors} stale (edit remaps them when unambiguous and refuses otherwise).`]; } /** * Every line an edit overwrites or removes must still be what the model was shown. Anchors verify * only the lines they name; this also covers range interiors, text replacements, and symbol * bodies. Refuses with the current rows, which then count as shown. */ function rejectStaleOverwrites(served: ServedLines | undefined, absolutePath: string, originalNormalized: string, result: string, physicalOriginal?: string, physicalResult?: string): EditErrorResult | undefined { if (!served?.has(absolutePath)) return undefined; const originalLines = originalNormalized.split("\n"); const touched = new Set(overwrittenLines(originalLines, normalizeToLF(result).split("\n"))); if (physicalOriginal !== undefined && physicalResult !== undefined) { const rows = (text: string) => { const buffer = new PhysicalLineBuffer(text); return buffer.lines.map((line, index) => line + buffer.endings[index]); }; for (const line of overwrittenLines(rows(physicalOriginal), rows(physicalResult))) touched.add(line); } const stale = served.findStale(absolutePath, originalLines, touched); if (!stale.length) return undefined; const feedback = formatStaleRows(originalLines, stale); served.record(absolutePath, feedback.rows); const count = stale.length === 1 ? "1 line" : `${stale.length} lines`; return buildEditError(absolutePath, "hash-mismatch", [ `Edit rejected — nothing was written. ${count} this edit would overwrite changed on disk since you last saw ${stale.length === 1 ? "it" : "them"} (>>> marks changed lines):`, "", feedback.text, "", "Decide against the current content above and re-issue the edit with these LINE:HASH anchors; no re-read is needed.", ].join("\n"), undefined, { updatedAnchors: feedback.rows }); } export interface EditToolOptions { wasReadInSession?: (absolutePath: string) => boolean; syntaxValidate?: SyntaxValidateOptions["syntaxValidate"]; /** What the model was shown of each file; refuses writes over lines that changed since. */ served?: ServedLines; /** Called when an edit result shows fresh anchors for a file (refusal feedback). */ onFileAnchored?: (absolutePath: string) => void; } async function validateEditSyntax(input: { absolutePath: string; originalNormalized: string; result: string; syntaxValidate: EditToolOptions["syntaxValidate"]; }): Promise> { const syntaxMode = resolveSyntaxValidateMode({ syntaxValidate: input.syntaxValidate }); if (syntaxMode === "off") return {}; const regression = await validateSyntaxRegression({ filePath: input.absolutePath, before: input.originalNormalized, after: input.result, }); if (!regression) return {}; const message = `syntax-regression: lines ${regression.errorLines.join(", ")}`; if (syntaxMode === "block") { return buildEditError(input.absolutePath, "syntax-regression", message); } return { warning: message }; } async function finalizeWrite(input: { absolutePath: string; displayPath: string; result: string; bom: string; originalEnding: ReturnType; postEditVerify: boolean; physicalResult?: string; }): Promise> { const writeContent = input.bom + (input.physicalResult ?? restoreLineEndings(input.result, input.originalEnding)); try { await writeFileAtomically(input.absolutePath, writeContent); } catch (err: any) { const wrapped = wrapWriteError(err, input.displayPath); const code = err?.code === "EACCES" || err?.code === "EPERM" ? "permission-denied" : err?.code === "ENOENT" ? "file-not-found" : "fs-error"; const message = code === "fs-error" && err?.message ? `${wrapped.message} — ${err.message}` : wrapped.message; return buildEditError( input.absolutePath, code, message, undefined, code === "fs-error" ? { fsCode: err?.code, fsMessage: err?.message } : undefined, ); } if (!input.postEditVerify) return { writeContent }; const contextHygiene = buildContextHygieneMetadata({ tool: "edit", classification: "mutation", resources: [buildFileResource(input.absolutePath)], }); let verifiedContent: string; try { verifiedContent = await fsReadFile(input.absolutePath, "utf-8"); } catch (err: any) { return buildEditError( input.absolutePath, "post-edit-verification-read-failed", `Edit write completed but post-edit verification failed: could not read ${input.displayPath} after writing.`, undefined, { fsCode: err?.code, fsMessage: err?.message }, contextHygiene, ); } if (verifiedContent !== writeContent) { return buildEditError( input.absolutePath, "post-edit-verification-mismatch", `Edit write completed but post-edit verification did not confirm the intended content for ${input.displayPath}. Re-read the file before making follow-up edits.`, undefined, { expectedLength: writeContent.length, actualLength: verifiedContent.length }, contextHygiene, ); } return { writeContent }; } type EditSuccessResult = { content: Array<{ type: "text"; text: string }>; details: EditToolDetails & { diffData: ReturnType; ptcValue: ReturnType["ptcValue"]; contextHygiene: ContextHygieneMetadata; }; }; async function buildEditResult(input: { absolutePath: string; displayPath: string; originalNormalized: string; result: string; physicalOriginal?: string; physicalResult?: string; probes: ReplaceSymbolProbe[]; anchorResult: AnchorEditResult; edits: EditItem[]; legacyNormalizationWarning?: string; replaceWarnings: string[]; replaceSymbolWarnings: string[]; syntaxWarning?: string; /** Notes about lines a cross-file move_lines removed from its source file. */ moveNotes?: string[]; /** Notes about anchors remapped through an earlier edit that moved their lines. */ anchorNotes?: string[]; }): Promise { const diffResult = generateCompactOrFullDiff(input.originalNormalized, input.result); const patch = createPatch(input.displayPath, input.physicalOriginal ?? input.originalNormalized, input.physicalResult ?? input.result); const blockRanges: DiffBlockRange[] = input.probes.map((probe) => ({ kind: "remove" as const, startLine: probe.range.start, endLine: probe.range.end, })); const diffData = buildDiffData({ path: input.absolutePath, oldContent: input.originalNormalized, newContent: input.result, diff: diffResult.diff, ...(blockRanges.length ? { blockRanges } : {}), }); const warnings: string[] = []; if (input.anchorResult.warnings?.length) warnings.push(...input.anchorResult.warnings); if (input.anchorNotes?.length) warnings.push(...input.anchorNotes); if (input.legacyNormalizationWarning) warnings.push(input.legacyNormalizationWarning); if (input.replaceWarnings.length) warnings.push(...input.replaceWarnings); if (input.replaceSymbolWarnings.length) warnings.push(...input.replaceSymbolWarnings); if (input.syntaxWarning) warnings.push(input.syntaxWarning); if (input.moveNotes?.length) warnings.push(...input.moveNotes); const internalClassification = classifyEdit(input.originalNormalized, input.result); const difftAvailable = await isDifftAvailable(); let semanticSummary: SemanticSummary = { classification: internalClassification.classification, difftasticAvailable: difftAvailable, }; if (difftAvailable) { const extension = input.displayPath.split(".").pop() ?? "txt"; const difftResult = await runDifftastic(input.originalNormalized, input.result, extension); if (difftResult) { semanticSummary = { classification: difftResult.classification, difftasticAvailable: true, ...(difftResult.movedBlocks > 0 ? { movedBlocks: difftResult.movedBlocks } : {}), }; } } const builtOutput = buildEditOutput({ path: input.absolutePath, displayPath: input.displayPath, diff: diffResult.diff, patch, diffData, firstChangedLine: input.anchorResult.firstChangedLine ?? diffResult.firstChangedLine, warnings, noopEdits: input.anchorResult.noopEdits ?? [], edits: input.edits, semanticSummary, }); return { content: [{ type: "text", text: builtOutput.text }], details: { diff: diffResult.diff, patch: builtOutput.patch, diffData, firstChangedLine: input.anchorResult.firstChangedLine ?? diffResult.firstChangedLine, ptcValue: builtOutput.ptcValue, contextHygiene: builtOutput.contextHygiene, } as EditSuccessResult["details"], }; } // ─── Registration ─────────────────────────────────────────────────────── export function registerEditTool(pi: ExtensionAPI, options: EditToolOptions = {}) { const tool = { name: "edit", label: "Edit", description: EDIT_PROMPT_METADATA.description, promptSnippet: EDIT_PROMPT_METADATA.promptSnippet, promptGuidelines: EDIT_PROMPT_METADATA.promptGuidelines, parameters: hashlineEditSchema, renderShell: "default" as const, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const normalized = normalizeToolParameters(hashlineEditSchema, params); if (normalized.requiredNull) { return buildRequiredNullParameterError("edit", normalized.requiredNull); } const parsed = normalized.value as HashlineParams; const input = normalized.value as Record; await ensureHashInit(); const rawPath = parsed.path; const path = rawPath.replace(/^@/, ""); const absolutePath = resolveToCwd(path, ctx.cwd); throwIfAborted(signal); try { const queueKey = await resolveMutationTargetPath(absolutePath); return await withFileMutationQueue(queueKey, async () => { throwIfAborted(signal); const validated = validateEdits({ parsed, rawInput: input, absolutePath, signal }); if (isEditErrorResult(validated)) return validated; const { edits, anchorEdits: requestedAnchorEdits, replaceEdits, replaceSymbolEdits, legacyNormalizationWarning } = validated; const notRead = requireReadForAnchors(options, absolutePath, rawPath, requestedAnchorEdits.length + replaceSymbolEdits.length > 0); if (notRead) return notRead; const loaded = await loadEditSource({ absolutePath, displayPath: path, signal }); if (isEditErrorResult(loaded)) return loaded; const { bom, originalEnding, originalNormalized, originalContent } = loaded; const shifted = remapShiftedAnchors({ served: options.served, path: absolutePath, currentLines: originalNormalized.split("\n"), edits: requestedAnchorEdits }); if (!shifted.ok) return recordErrorFeedback(options, absolutePath, buildEditError(absolutePath, "hash-mismatch", shifted.message, undefined, { updatedAnchors: shifted.rows })); const anchorEdits = shifted.edits; const preserveLineEndings = true; const resolvedSymbols = await resolveReplaceSymbols({ absolutePath, originalNormalized, replaceSymbolEdits, }); if (isEditErrorResult(resolvedSymbols)) return resolvedSymbols; const symbolOverlapError = validateReplaceSymbolOverlaps({ absolutePath, probes: resolvedSymbols, anchorEdits, }); if (symbolOverlapError) return symbolOverlapError; const symbolApplication = applyResolvedReplaceSymbols(originalContent, resolvedSymbols, true); const rsProbeResults = resolvedSymbols; const replaceSymbolWarnings = symbolApplication.warnings; let result = symbolApplication.content; const anchorResult = await applyAnchorEdits({ absolutePath, content: result, anchorEdits, cwd: ctx.cwd, signal, preserveLineEndings }); if (isEditErrorResult(anchorResult)) return recordErrorFeedback(options, absolutePath, anchorResult); result = anchorResult.content; const replacementResult = applyReplaceEdits({ absolutePath, displayPath: path, content: result, replaceEdits, signal, preserveLineEndings, }); if (isEditErrorResult(replacementResult)) return recordErrorFeedback(options, absolutePath, replacementResult); result = replacementResult.content; const normalizedResult = normalizeToLF(result); const replaceWarnings = replacementResult.warnings; const noopError = detectNoop({ absolutePath, displayPath: path, originalNormalized: preserveLineEndings ? originalContent : originalNormalized, result, edits, anchorResult, }); if (noopError) return noopError; const retype = await findCorruptedRetype({ edits: anchorEdits, absolutePath, currentContent: originalNormalized, candidatePaths: options.served?.paths() ?? [], cwd: ctx.cwd }); if (retype) return buildEditError(absolutePath, "corrupted-retype", retype.message); const staleError = rejectStaleOverwrites(options.served, absolutePath, originalNormalized, normalizedResult, originalContent, result); if (staleError) options.onFileAnchored?.(absolutePath); if (staleError) return staleError; const sourceRemovals = await planSourceRemovals({ anchorEdits, absolutePath, cwd: ctx.cwd, options, signal }); if (isEditErrorResult(sourceRemovals)) return sourceRemovals; throwIfAborted(signal); const syntaxResult = await validateEditSyntax({ absolutePath, originalNormalized, result: normalizedResult, syntaxValidate: options.syntaxValidate, }); if (isEditErrorResult(syntaxResult)) return syntaxResult; const syntaxWarning = syntaxResult.warning; const writeResult = await finalizeWrite({ absolutePath, displayPath: path, result, bom, originalEnding, postEditVerify: input.postEditVerify === true, physicalResult: preserveLineEndings ? result : undefined, }); if (isEditErrorResult(writeResult)) return writeResult; const shift = options.served?.remapAfterWrite(absolutePath, originalNormalized.split("\n"), normalizedResult.split("\n"), replaceEdits.length || replaceSymbolEdits.length ? undefined : anchorResult.lineMap); const moveNotes = await commitSourceRemovals(sourceRemovals, options, path); if (isEditErrorResult(moveNotes)) return moveNotes; return await buildEditResult({ absolutePath, displayPath: path, originalNormalized, result: normalizedResult, physicalOriginal: bom + originalContent, physicalResult: writeResult.writeContent, probes: rsProbeResults, anchorResult, edits, legacyNormalizationWarning, replaceWarnings, replaceSymbolWarnings, syntaxWarning, moveNotes, anchorNotes: [...shifted.notes, ...describeOwnShift(shift)], }); }); } catch (err: any) { const code = err?.code; if (typeof code === "string") { const message = `File not readable: ${path}${err?.message ? ` — ${err.message}` : ""}`; return buildEditError(absolutePath, "fs-error", message, undefined, { fsCode: code, fsMessage: err?.message }); } throw err; } }, renderCall(args: any, theme: any, ...rest: any[]) { const context: { argsComplete?: boolean; executionStarted?: boolean; lastComponent?: any; cwd?: string; state?: Record; invalidate?: () => void; width?: number; expanded?: boolean } = rest[0] ?? {}; const cwd = context.cwd ?? process.cwd(); const argsComplete = context.argsComplete ?? false; const { path: filePath, suffix } = formatEditCallText(args, argsComplete); let text = theme.fg("toolTitle", theme.bold("edit")); if (filePath) text += ` ${linkToolPath(theme.fg("accent", filePath), filePath, cwd)}`; else text += ` ${theme.fg("toolOutput", "...")}`; const counts = Array.isArray(args?.edits) ? countEditTypes(args.edits) : undefined; if (counts && counts.total > 0) { text += ` ${theme.fg("dim", `(${counts.total} ${counts.total === 1 ? "edit" : "edits"})`)}`; } else if (suffix) { text += ` ${theme.fg("dim", suffix)}`; } text = clampLineToWidth(text, context.width); // Once execution has started, the pending preview's only job is done: // renderResult will carry the story ("↳ edited +N -M" with the same // expandable diff). Keeping the "↳ pending edit" sub-line and its // preview alongside the final result is just duplicate noise. if (context.executionStarted) { const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent)) ? context.lastComponent : new Text("", 0, 0); textComponent.setText(text); return textComponent; } const contextExpanded = !!context.expanded; const settingExpanded = resolveEditDiffDisplay() === "expanded"; const expanded = contextExpanded || settingExpanded; const argsStable = context.argsComplete === true; const previewEligible = expanded && argsStable; const previewKey = previewEligible ? buildEditPreviewKey(args ?? {}) : undefined; const preview = previewEligible ? resolvePendingDiffPreview( context, EDIT_PENDING_PREVIEW_STATE_KEY, previewKey, () => buildPendingEditPreviewData(args ?? {}, context.cwd ?? process.cwd(), options.served), ) : undefined; const preview2: ReturnType = !expanded && argsStable ? { lines: [text, summaryLine("pending edit", { hidden: true })], headerLabel: "pending edit" } : pendingPreviewLines(text, preview, expanded); if (preview2.diffData) { const diffComponent = context.lastComponent instanceof DiffPreviewComponent ? context.lastComponent : new DiffPreviewComponent({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true }); diffComponent.update({ prefixLines: preview2.lines, diffData: preview2.diffData, theme, expanded: true }); return diffComponent; } const textComponent = (context.lastComponent && !(context.lastComponent instanceof DiffPreviewComponent)) ? context.lastComponent : new Text("", 0, 0); textComponent.setText(clampLinesToWidth(preview2.lines, context.width).join("\n")); return textComponent; }, renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) { const context: { isPartial?: boolean; isError?: boolean; expanded?: boolean; lastComponent?: any; width?: number } = rest[0] ?? options ?? {}; const isPartial = context.isPartial ?? (options as any)?.isPartial ?? false; const isError = context.isError ?? false; if (isPartial) { const width = (context as any).width ?? (options as any)?.width; return new Text(clampLinesToWidth([summaryLine("pending edit")], width).join("\n"), 0, 0); } // Extract data from result const textContent = result.content ?.filter((c: any) => c.type === "text") .map((c: any) => c.text || "") .join("\n") ?? ""; const details = result.details ?? {}; const diff: string = details.diff ?? ""; const ptcValue = details.ptcValue as { warnings?: string[]; noopEdits?: unknown[]; } | undefined; const warnings = ptcValue?.warnings ?? []; const noopEdits = ptcValue?.noopEdits ?? []; const semanticClassification = (ptcValue as any)?.semanticSummary?.classification as string | undefined; const info = formatEditResultText({ isError: isError || !!result.isError, diff, warnings, noopEdits, errorText: textContent, semanticClassification: semanticClassification as any, }); const expanded = isRendererExpanded(options as any, context as any) || resolveEditDiffDisplay() === "expanded"; const width = (context as any).width ?? (options as any)?.width; const diffData = (details as any).diffData; const stats = diffData?.stats ?? { added: 0, removed: 0 }; let text = ""; if (info.noOp) { text = summaryLine("no-op"); if (expanded && info.errorText) text += `\n${theme.fg("error", info.errorText)}`; } else if (info.errorText) { const firstLine = info.errorText.split("\n")[0] || "Error"; text = summaryLine(expanded ? info.errorText : firstLine); } else { const badges: string[] = [`edited +${stats.added} -${stats.removed}`]; if (info.semanticBadge) badges.push(info.semanticBadge.replace(/^✓\s*/, "")); if (info.warningsBadge) badges.push(info.warningsBadge); text = summaryLine(badges.join(" • "), { hidden: !!diffData && !expanded }); if (expanded && diffData) { const diffComponent = context.lastComponent instanceof DiffPreviewComponent ? context.lastComponent : new DiffPreviewComponent({ prefixLines: text.split("\n"), diffData, theme, expanded: true }); diffComponent.update({ prefixLines: text.split("\n"), diffData, theme, expanded: true }); return diffComponent; } } return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0); }, } satisfies Parameters[0]; pi.registerTool(tool); return tool; }