pi-hashline-edit-pro 2.6.4 → 2.7.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/src/insert.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { constants } from "fs";
5
+ import { execPipeline, type ReqParams, type ReplaceDetails } from "./replace";
6
+ import { commitEdit } from "./commit";
7
+ import { readNormFile, type NormFile } from "./file-reader";
8
+ import { resolveTarget } from "./fs-write";
9
+ import { MAX_HASH_LINES, parseHashRef, resolveAnchorLine, type Anchor } from "./hashline";
10
+ import { stripAnchorRow } from "./hashline/resolve";
11
+ import { toCwd } from "./paths";
12
+ import { loadP, loadGuide } from "./prompts";
13
+ import { normReq } from "./replace-normalize";
14
+ import { genDiff } from "./replace-diff";
15
+ import { makeRenderCall, renderEditResult, type RPreview, type RRState } from "./replace-render";
16
+ import { abortIf, isRec, makePrepareArguments, rejectUnknownFields, splitLines } from "./utils";
17
+ import { clearBoundaryBypass } from "./boundary-bypass";
18
+
19
+ const INSERT_KS = new Set(["path", "anchor", "direction", "lines"]);
20
+
21
+ export interface InsertReq {
22
+ path: string;
23
+ anchor: string;
24
+ direction: "before" | "after";
25
+ lines: string[];
26
+ }
27
+
28
+ export function assertInsertReq(request: unknown): asserts request is InsertReq {
29
+ if (!isRec(request)) {
30
+ throw new Error("[E_BAD_SHAPE] Insert request must be an object.");
31
+ }
32
+ rejectUnknownFields(request, INSERT_KS, "Insert request");
33
+ if (typeof request.path !== "string" || request.path.length === 0) {
34
+ throw new Error('[E_BAD_SHAPE] Insert request requires a non-empty "path" string.');
35
+ }
36
+ if (typeof request.anchor !== "string" || request.anchor.length === 0) {
37
+ throw new Error('[E_BAD_SHAPE] Insert request requires an "anchor" string (3-char hash from read output).');
38
+ }
39
+ if (request.direction !== "before" && request.direction !== "after") {
40
+ throw new Error('[E_BAD_SHAPE] Insert request "direction" must be "before" or "after".');
41
+ }
42
+ if (!Array.isArray(request.lines) || request.lines.some((line) => typeof line !== "string")) {
43
+ throw new Error('[E_BAD_SHAPE] Insert request requires "lines" as an array of strings, one element per line.');
44
+ }
45
+ }
46
+
47
+ const insertToolSchema = Type.Object(
48
+ {
49
+ path: Type.String({
50
+ description:
51
+ "Path to the file to edit",
52
+ }),
53
+ anchor: Type.String({
54
+ description:
55
+ 'Bare 3-char HASH only (e.g. "aB3"): copy just the hash from the leftmost column of a read row like `aB3│content`; never the line content. A pasted diff row like `+aB3│x` or a `HASH│` prefix is stripped automatically with a warning. The anchored line is preserved; the new lines go after or before it.',
56
+ }),
57
+ direction: Type.Union(
58
+ [
59
+ Type.Literal("after", { description: "Insert the lines after the anchor line" }),
60
+ Type.Literal("before", { description: "Insert the lines before the anchor line" }),
61
+ ],
62
+ { description: '"after" or "before"' },
63
+ ),
64
+ lines: Type.Array(
65
+ Type.String({
66
+ description:
67
+ "One line to insert. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
68
+ }),
69
+ {
70
+ description:
71
+ 'Lines to insert as an array of strings, one element per line. Use [""] for a blank line. The anchor line is preserved; never include it in lines.',
72
+ }
73
+ ),
74
+ },
75
+ { additionalProperties: false },
76
+ );
77
+
78
+ function parseInsertAnchor(raw: string): { ref: Anchor; warnings: string[] } {
79
+ const trimmedAnchor = raw.trim();
80
+ const warnings: string[] = [];
81
+ const anchorText = stripAnchorRow(trimmedAnchor, "anchor entry", warnings);
82
+ return { ref: parseHashRef(anchorText), warnings };
83
+ }
84
+
85
+ function buildInsertEdit(
86
+ req: InsertReq,
87
+ preload: NormFile,
88
+ ref: Anchor,
89
+ ): { editParams: ReqParams; anchorLine: string | undefined } {
90
+ const fileLines = splitLines(preload.normalized);
91
+ const line = resolveAnchorLine(ref, fileLines, preload.fileHashes, req.path);
92
+ const anchorLine = preload.normalized.length === 0 ? undefined : fileLines[line - 1];
93
+ const editParams: ReqParams = {
94
+ path: req.path,
95
+ remove_from: ref.hash,
96
+ remove_to: ref.hash,
97
+ replacement_lines:
98
+ anchorLine === undefined
99
+ ? [...req.lines]
100
+ : req.direction === "after"
101
+ ? [anchorLine, ...req.lines]
102
+ : [...req.lines, anchorLine],
103
+ };
104
+ return { editParams, anchorLine };
105
+ }
106
+
107
+ export async function insertPreview(request: unknown, cwd: string): Promise<RPreview> {
108
+ try {
109
+ const normalized = normReq(request);
110
+ assertInsertReq(normalized);
111
+ const { ref } = parseInsertAnchor(normalized.anchor);
112
+ const preload = await readNormFile(normalized.path, cwd, {
113
+ accessMode: constants.R_OK,
114
+ maxLines: MAX_HASH_LINES,
115
+ noPersist: true,
116
+ });
117
+ const { editParams } = buildInsertEdit(normalized, preload, ref);
118
+ const pipe = await execPipeline(editParams, cwd, {
119
+ accessMode: constants.R_OK,
120
+ noPersist: true,
121
+ preloadedNorm: preload,
122
+ skipBoundaryDedup: true,
123
+ });
124
+ if (pipe.originalNormalized === pipe.result) {
125
+ return { error: `No changes made to ${normalized.path}. The edit produced identical content.` };
126
+ }
127
+ return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
128
+ } catch (error: unknown) {
129
+ return { error: error instanceof Error ? error.message : String(error) };
130
+ }
131
+ }
132
+
133
+ function getInsertInput(args: unknown): { path?: string; anchor?: string; direction?: "before" | "after"; lines?: string[] } | null {
134
+ let normalized: unknown;
135
+ try {
136
+ normalized = normReq(args);
137
+ } catch {
138
+ return null;
139
+ }
140
+ if (!isRec(normalized) || typeof normalized.path !== "string") return null;
141
+ if (
142
+ typeof normalized.anchor !== "string" ||
143
+ (normalized.direction !== "before" && normalized.direction !== "after") ||
144
+ !Array.isArray(normalized.lines) ||
145
+ normalized.lines.some((line) => typeof line !== "string")
146
+ ) {
147
+ return null;
148
+ }
149
+ return {
150
+ path: normalized.path,
151
+ anchor: normalized.anchor,
152
+ direction: normalized.direction,
153
+ lines: normalized.lines,
154
+ };
155
+ }
156
+
157
+ type InsertToolDef = ToolDefinition<any, ReplaceDetails, RRState> & { renderShell?: "default" | "self" };
158
+
159
+ export function buildInsertToolDef(): InsertToolDef {
160
+ return {
161
+ name: "insert",
162
+ label: "Insert",
163
+ description: loadP("../prompts/insert.md"),
164
+ promptSnippet: loadP("../prompts/insert-snippet.md"),
165
+ promptGuidelines: loadGuide("../prompts/insert-guidelines.md"),
166
+ prepareArguments: makePrepareArguments(),
167
+ parameters: insertToolSchema,
168
+ renderShell: "default",
169
+ renderCall: makeRenderCall(insertPreview, { getInput: getInsertInput, toolName: "insert" }),
170
+ renderResult(result, { isPartial }, theme, context) {
171
+ return renderEditResult(
172
+ result as {
173
+ content?: Array<{ type: string; text?: string }>;
174
+ details?: ReplaceDetails;
175
+ },
176
+ isPartial,
177
+ theme,
178
+ context,
179
+ );
180
+ },
181
+
182
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
183
+ const canonical = normReq(params);
184
+ assertInsertReq(canonical);
185
+ const req = canonical;
186
+ const path = req.path;
187
+ const { ref, warnings: anchorWarnings } = parseInsertAnchor(req.anchor);
188
+ const absolutePath = toCwd(path, ctx.cwd);
189
+ const mutationTargetPath = await resolveTarget(absolutePath);
190
+ return withFileMutationQueue(mutationTargetPath, async () => {
191
+ abortIf(signal);
192
+ const preload = await readNormFile(path, ctx.cwd, {
193
+ signal,
194
+ accessMode: constants.R_OK | constants.W_OK,
195
+ maxLines: MAX_HASH_LINES,
196
+ });
197
+ const { editParams, anchorLine } = buildInsertEdit(req, preload, ref);
198
+ const pipe = await execPipeline(editParams, ctx.cwd, {
199
+ accessMode: constants.R_OK | constants.W_OK,
200
+ signal,
201
+ preloadedNorm: preload,
202
+ skipBoundaryDedup: true,
203
+ });
204
+ return commitEdit(pipe, {
205
+ path,
206
+ absolutePath,
207
+ mutationTargetPath,
208
+ signal,
209
+ verb: "inserted",
210
+ noopNoun: "Insertion",
211
+ foldedAnchorLines: anchorLine === undefined ? 0 : 1,
212
+ prefixWarnings: anchorWarnings,
213
+ onApplied: () => clearBoundaryBypass(mutationTargetPath),
214
+ });
215
+ });
216
+ },
217
+ };
218
+ }
219
+
220
+ export function regInsert(pi: ExtensionAPI): void {
221
+ pi.registerTool(buildInsertToolDef());
222
+ }
package/src/read.ts CHANGED
@@ -72,7 +72,7 @@ export async function fmtReadPreview(
72
72
  };
73
73
  }
74
74
  return {
75
- text: `Offset ${startLine} is beyond end of file (0 lines total). The file is empty. Use replace to insert content.`,
75
+ text: `Offset ${startLine} is beyond end of file (0 lines). Use replace to insert content.`,
76
76
  servedHashes: [],
77
77
  };
78
78
  }
@@ -114,7 +114,7 @@ export async function fmtReadPreview(
114
114
  const lineLabel = oversized.length === 1 ? `Line ${oversized[0]!.lineNumber}` : `Lines ${oversized.map((row) => row.lineNumber).join(", ")}`;
115
115
  const verb = oversized.length === 1 ? "exceeds" : "exceed";
116
116
  const addresses = oversized.map((row) => `${row.lineNumber}p`).join(";");
117
- const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown because hashline anchors require full lines. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
117
+ const warning = `[${lineLabel} ${verb} ${formatSize(maxBytes)}; content not shown. Inspect with bash: sed -n '${addresses}' <path> | head -c ${maxBytes}]`;
118
118
  let preview = skippedTruncation.content;
119
119
  let nextOffset: number | undefined;
120
120
  if (shownRowCount > 0 && (skippedTruncation.truncated || lastShownLine < totalLines)) {
@@ -47,6 +47,10 @@ function fmtDiffLine(
47
47
  return `${prefix}${hash}${HASH_SEP}${line}`;
48
48
  }
49
49
 
50
+ function isBlankLine(line: string): boolean {
51
+ return line.trim().length === 0;
52
+ }
53
+
50
54
  const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis");
51
55
  const isEllipsisMarker = (line: string | symbol): line is symbol =>
52
56
  line === ELLIPSIS_MARKER;
@@ -99,15 +103,52 @@ export function genDiff(
99
103
  let skipTail = 0;
100
104
 
101
105
  if (!lastWasChange) {
102
- skipStart = Math.max(0, displayLines.length - contextLines);
106
+ let count = contextLines;
107
+ if (
108
+ contextLines > 0 &&
109
+ displayLines.length > count &&
110
+ isBlankLine(displayLines[displayLines.length - 1]!)
111
+ ) {
112
+ count += 1;
113
+ }
114
+ count = Math.min(count, displayLines.length);
115
+ skipStart = displayLines.length - count;
103
116
  linesToShow = displayLines.slice(skipStart);
104
117
  } else if (nextPartIsChange && displayLines.length > contextLines * 2) {
105
- const tail = displayLines.slice(-contextLines);
106
- linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail];
107
- skipMiddle = displayLines.length - contextLines * 2;
118
+ let headCount = contextLines;
119
+ let tailCount = contextLines;
120
+ if (
121
+ contextLines > 0 &&
122
+ displayLines.length - headCount > tailCount &&
123
+ isBlankLine(displayLines[headCount - 1]!)
124
+ ) {
125
+ headCount += 1;
126
+ }
127
+ if (
128
+ contextLines > 0 &&
129
+ displayLines.length - tailCount > headCount &&
130
+ isBlankLine(displayLines[displayLines.length - tailCount]!)
131
+ ) {
132
+ tailCount += 1;
133
+ }
134
+ const middleLen = displayLines.length - headCount - tailCount;
135
+ if (middleLen > 0) {
136
+ linesToShow = [
137
+ ...displayLines.slice(0, headCount),
138
+ ELLIPSIS_MARKER,
139
+ ...displayLines.slice(displayLines.length - tailCount),
140
+ ];
141
+ skipMiddle = middleLen;
142
+ } else {
143
+ linesToShow = displayLines;
144
+ }
108
145
  } else if (!nextPartIsChange && linesToShow.length > contextLines) {
109
- linesToShow = linesToShow.slice(0, contextLines);
110
- skipTail = displayLines.length - contextLines;
146
+ let count = contextLines;
147
+ const firstLine = linesToShow[0];
148
+ if (contextLines > 0 && typeof firstLine === "string" && isBlankLine(firstLine)) count += 1;
149
+ count = Math.min(count, linesToShow.length);
150
+ linesToShow = linesToShow.slice(0, count);
151
+ skipTail = displayLines.length - count;
111
152
  }
112
153
 
113
154
  if (skipStart > 0) {
@@ -1,3 +1,4 @@
1
+ import { Markdown, Text } from "@earendil-works/pi-tui";
1
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
3
  import { normReq } from "./replace-normalize";
3
4
  import type { ReqParams, ReplaceDetails } from "./replace";
@@ -89,17 +90,18 @@ export function fmtResult(diff: string, theme: FgT): string {
89
90
  }
90
91
 
91
92
  export function fmtCall(
92
- args: ReqParams | undefined,
93
+ args: { path?: string } | undefined,
93
94
  state: RRState,
94
95
  expanded: boolean,
95
96
  theme: CallT,
97
+ toolName = "replace",
96
98
  ): string {
97
99
  const path = args?.path;
98
100
  const pathDisplay =
99
101
  typeof path === "string" && path.length > 0
100
102
  ? theme.fg("accent", path)
101
103
  : theme.fg("toolOutput", "...");
102
- let text = `${theme.fg("toolTitle", theme.bold("replace"))} ${pathDisplay}`;
104
+ let text = `${theme.fg("toolTitle", theme.bold(toolName))} ${pathDisplay}`;
103
105
 
104
106
  if (!state.preview) {
105
107
  return text;
@@ -209,3 +211,116 @@ export function mkMdTheme(theme: MdTheme) {
209
211
  }),
210
212
  };
211
213
  }
214
+
215
+ export const PREVIEW_DEBOUNCE_MS = 150;
216
+
217
+ export function reuseText(context: any, content: string): Text {
218
+ const t = context.lastComponent instanceof Text
219
+ ? context.lastComponent
220
+ : new Text("", 0, 0);
221
+ t.setText(content);
222
+ return t;
223
+ }
224
+
225
+ export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
226
+ const m = context.lastComponent instanceof Markdown
227
+ ? context.lastComponent
228
+ : new Markdown("", 0, 0, mkMdTheme(theme));
229
+ m.setText(content);
230
+ return m;
231
+ }
232
+
233
+ export function makeRenderCall(
234
+ preview: (args: unknown, cwd: string) => Promise<RPreview>,
235
+ options: { getInput?: (args: unknown) => { path?: string } | null; toolName?: string } = {},
236
+ ) {
237
+ const getInput = options.getInput ?? getPreviewInput;
238
+ const toolName = options.toolName ?? "replace";
239
+ return (args: any, theme: CallT, context: any): Text => {
240
+ const previewInput = getInput(args);
241
+ const cancelPendingPreview = () => {
242
+ if (context.state.previewTimer) {
243
+ clearTimeout(context.state.previewTimer);
244
+ context.state.previewTimer = undefined;
245
+ }
246
+ };
247
+ if (context.executionStarted) {
248
+ cancelPendingPreview();
249
+ context.state.argsKey = undefined;
250
+ context.state.preview = undefined;
251
+ context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
252
+ } else if (!context.argsComplete || !previewInput) {
253
+ cancelPendingPreview();
254
+ context.state.argsKey = undefined;
255
+ context.state.preview = undefined;
256
+ context.state.previewGeneration = (context.state.previewGeneration ?? 0) + 1;
257
+ } else {
258
+ const argsKey = JSON.stringify(previewInput);
259
+ if (context.state.argsKey !== argsKey) {
260
+ cancelPendingPreview();
261
+ context.state.argsKey = argsKey;
262
+ context.state.preview = undefined;
263
+ const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
264
+ context.state.previewGeneration = previewGeneration;
265
+ context.state.previewTimer = setTimeout(() => {
266
+ context.state.previewTimer = undefined;
267
+ preview(args, context.cwd)
268
+ .then((result) => {
269
+ if (
270
+ context.state.argsKey === argsKey &&
271
+ context.state.previewGeneration === previewGeneration
272
+ ) {
273
+ context.state.preview = result;
274
+ context.invalidate();
275
+ }
276
+ })
277
+ .catch((err: unknown) => {
278
+ if (
279
+ context.state.argsKey === argsKey &&
280
+ context.state.previewGeneration === previewGeneration
281
+ ) {
282
+ context.state.preview = {
283
+ error: err instanceof Error ? err.message : String(err),
284
+ };
285
+ context.invalidate();
286
+ }
287
+ });
288
+ }, PREVIEW_DEBOUNCE_MS);
289
+ }
290
+ }
291
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
292
+ text.setText(fmtCall(getInput(args) ?? undefined, context.state as RRState, context.expanded, theme, toolName));
293
+ return text;
294
+ };
295
+ }
296
+
297
+ export function renderEditResult(
298
+ result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
299
+ isPartial: boolean,
300
+ theme: FgT,
301
+ context: any,
302
+ ): Text | Markdown {
303
+ if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
304
+ const renderedText = getResultText(result);
305
+ const renderState = context.state as RRState | undefined;
306
+ if (renderState) {
307
+ if (renderState.previewTimer) {
308
+ clearTimeout(renderState.previewTimer);
309
+ renderState.previewTimer = undefined;
310
+ }
311
+ renderState.preview = undefined;
312
+ renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
313
+ }
314
+ if (context.isError) {
315
+ return renderedText
316
+ ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
317
+ : new Text("", 0, 0);
318
+ }
319
+ if (isApplied(result.details)) {
320
+ const appliedText = buildAppliedText(renderedText, result.details, theme);
321
+ return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
322
+ }
323
+ if (!renderedText) return new Text("", 0, 0);
324
+ return reuseMarkdown(context, fmtResultMd(renderedText), theme);
325
+ }
326
+
@@ -2,7 +2,7 @@ import type { ReplaceDetails } from "./replace";
2
2
  import { genDiff, genPatch } from "./replace-diff";
3
3
  import { visLines, clipLine } from "./utils";
4
4
 
5
- type TResult = {
5
+ export type TResult = {
6
6
  content: Array<{ type: "text"; text: string }>;
7
7
  isError?: boolean;
8
8
  details: ReplaceDetails;
@@ -38,6 +38,7 @@ export interface NoopInput {
38
38
  snapshotId?: string;
39
39
  editMeta: RMeta;
40
40
  warnings: string[] | undefined;
41
+ boundaryRemovedLines?: number;
41
42
  }
42
43
 
43
44
  export interface SuccessInput {
@@ -88,20 +89,25 @@ function warnBlock(warnings: string[] | undefined): string {
88
89
  return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
89
90
  }
90
91
 
91
- export function buildNoop(input: NoopInput): TResult {
92
+ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
92
93
  const {
93
94
  path,
94
95
  noopEdit,
95
96
  snapshotId,
96
97
  editMeta,
97
98
  warnings,
99
+ boundaryRemovedLines,
98
100
  } = input;
99
101
 
100
102
  const noopDetailsText = noopEdit
101
- ? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
103
+ ? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
102
104
  : "The edit produced identical content.";
105
+ const dedupNote =
106
+ boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
107
+ ? `\nBoundary dedup removed ${boundaryRemovedLines} line(s) from the replacement. Send the same edit again to apply it literally.`
108
+ : "";
103
109
 
104
- const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${warnBlock(warnings)}`;
110
+ const text = `No changes made to ${path}\nClassification: noop\n${noopDetailsText}${dedupNote}${warnBlock(warnings)}`;
105
111
 
106
112
  const metrics = buildMetrics({
107
113
  classification: "noop",
@@ -123,14 +129,14 @@ export function buildNoop(input: NoopInput): TResult {
123
129
  };
124
130
  }
125
131
 
126
- export function buildChanged(input: SuccessInput): TResult {
132
+ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
127
133
  const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
128
134
  const resultLines = visLines(result);
129
135
  const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
130
136
  const addedLines = editMeta.addedLines;
131
137
  const removedLines = editMeta.removedLines;
132
138
  const warningsBlock = warnBlock(warnings);
133
- const successPrefix = `Successfully replaced in ${path}.`;
139
+ const successPrefix = `Successfully ${verb} in ${path}.`;
134
140
  const lineSummary = addedLines > 0 || removedLines > 0
135
141
  ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
136
142
  : "";
@@ -85,13 +85,13 @@ export async function clearUndo(path: string): Promise<void> {
85
85
  }
86
86
  }
87
87
 
88
- export function regReplaceUndo(pi: ExtensionAPI): void {
88
+ export function regUndo(pi: ExtensionAPI): void {
89
89
  pi.registerTool({
90
- name: "undo_last_replace",
91
- label: "Undo Last Replace",
92
- description: loadP("../prompts/undo-last-replace.md"),
93
- promptSnippet: loadP("../prompts/undo-last-replace-snippet.md"),
94
- promptGuidelines: loadGuide("../prompts/undo-last-replace-guidelines.md"),
90
+ name: "undo_last_change",
91
+ label: "Undo Last Change",
92
+ description: loadP("../prompts/undo-last-change.md"),
93
+ promptSnippet: loadP("../prompts/undo-last-change-snippet.md"),
94
+ promptGuidelines: loadGuide("../prompts/undo-last-change-guidelines.md"),
95
95
  prepareArguments: makePrepareArguments(),
96
96
  parameters: Type.Object({
97
97
  path: Type.String({
@@ -110,7 +110,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
110
110
  content: [
111
111
  {
112
112
  type: "text",
113
- text: `No undo history for ${path}. There is no previous replace to revert.`,
113
+ text: `No undo history for ${path}.`,
114
114
  },
115
115
  ],
116
116
  isError: true,
@@ -132,7 +132,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
132
132
  content: [
133
133
  {
134
134
  type: "text",
135
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists. Call read() to inspect the current state.`
135
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file no longer exists.`
136
136
  },
137
137
  ],
138
138
  isError: true,
@@ -145,7 +145,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
145
145
  content: [
146
146
  {
147
147
  type: "text",
148
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file was modified after the replace, so undoing would overwrite those changes. Call read() to inspect the current state.`
148
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. Call read() to inspect the current state.`
149
149
  },
150
150
  ],
151
151
  isError: true,
@@ -178,15 +178,15 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
178
178
  await clearUndo(mutationTargetPath);
179
179
 
180
180
  const parts: string[] = [
181
- `Undone last replace on ${path}.`,
181
+ `Undone last change on ${path}.`,
182
182
  ];
183
183
  if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
184
184
  parts.push(
185
- `Removed ${linesAddedByReplace} line(s) that were added and restored ${linesRemovedByReplace} line(s) that were removed.`,
185
+ `Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
186
186
  );
187
187
  }
188
188
  parts.push(
189
- "File reverted to previous state. Call `read` to get fresh anchors for follow-up edits.",
189
+ "Call read for fresh anchors.",
190
190
  );
191
191
 
192
192
  return {