pi-hashline-edit-pro 2.6.5 → 2.7.1

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.
@@ -1,12 +1,11 @@
1
1
  import { abortIf, rejectUnknownFields, firstNonEmptyIndex, lastNonEmptyIndex, clipLine } from "../utils";
2
- import { HASH_SEP, HASH_RUN, HL_BARE_PREFIX_RE, HL_PREFIX_PLUS_RE, HL_PREFIX_MINUS_RE, canon } from "./hash";
2
+ import { HASH_SEP, HASH_RUN, stripRowPrefix, canon } from "./hash";
3
3
  import { parseHashRef, parseText, type Anchor } from "./parse";
4
4
  import { NEW_CONTENT_NOT_ARRAY_MSG, MAX_RANGE_STALE_LINES } from "../constants";
5
5
 
6
6
  export type RAnchor = {
7
7
  line: number;
8
8
  hash: string;
9
- hashMatched: boolean;
10
9
  };
11
10
 
12
11
  export type HEdit = { content_lines: string[]; hash_bounds: [Anchor, Anchor] };
@@ -56,7 +55,6 @@ function resAnchorFromMap(
56
55
  return {
57
56
  line: hashMatches[0]!,
58
57
  hash: ref.hash,
59
- hashMatched: true,
60
58
  };
61
59
  }
62
60
  return { ref, kind: "ambiguous", candidates: hashMatches };
@@ -74,6 +72,19 @@ function assertAligned(
74
72
  }
75
73
  }
76
74
 
75
+ export function fmtRow(hash: string, line: string): string {
76
+ return `${hash}${HASH_SEP}${line}`;
77
+ }
78
+
79
+ export function fmtRegion(hashes: string[], lines: string[]): string {
80
+ if (hashes.length !== lines.length) {
81
+ throw new Error(
82
+ `fmtRegion: hashes.length (${hashes.length}) must match lines.length (${lines.length}).`,
83
+ );
84
+ }
85
+ return lines.map((line, index) => fmtRow(hashes[index]!, line)).join("\n");
86
+ }
87
+
77
88
  export function fmtMismatchWithHashes(
78
89
  mismatches: HMismatch[],
79
90
  fileLines: string[],
@@ -164,26 +175,29 @@ function assertItem(edit: Record<string, unknown>): void {
164
175
 
165
176
  export const ANCHOR_ROW_RE = new RegExp(`^([+-]?)(${HASH_RUN})│`);
166
177
 
178
+ export function stripAnchorRow(
179
+ trimmed: string,
180
+ entryLabel: string,
181
+ warnings?: string[],
182
+ ): string {
183
+ const match = trimmed.match(ANCHOR_ROW_RE);
184
+ if (!match) return trimmed;
185
+ const marker =
186
+ match[1] === "+"
187
+ ? "diff-preview marker"
188
+ : match[1] === "-"
189
+ ? 'leading "-" marker'
190
+ : '"HASH│" prefix';
191
+ warnings?.push(`[E_BAD_REF] Stripped ${marker} from ${entryLabel} "${trimmed}".`);
192
+ return match[2]!;
193
+ }
194
+
167
195
  export function resEdit(edit: HTEdit, warnings?: string[]): HEdit {
168
196
  assertItem(edit as Record<string, unknown>);
169
197
 
170
198
  const replaceLines = parseText(edit.replacement_lines, warnings);
171
199
  const bounds = [edit.remove_from, edit.remove_to].map((ref) => {
172
- const trimmed = ref.trim();
173
- const match = trimmed.match(ANCHOR_ROW_RE);
174
- if (match) {
175
- let message: string;
176
- if (match[1] === "+") {
177
- message = `[E_BAD_REF] Stripped diff-preview marker from remove_from/remove_to entry "${trimmed}".`;
178
- } else if (match[1] === "-") {
179
- message = `[E_BAD_REF] Stripped leading "-" marker from remove_from/remove_to entry "${trimmed}".`;
180
- } else {
181
- message = `[E_BAD_REF] Stripped "HASH│" prefix from remove_from/remove_to entry "${trimmed}".`;
182
- }
183
- warnings?.push(message);
184
- return match[2]!;
185
- }
186
- return ref;
200
+ return stripAnchorRow(ref.trim(), "remove_from/remove_to entry", warnings);
187
201
  }) as [string, string];
188
202
  return {
189
203
  content_lines: replaceLines,
@@ -210,10 +224,10 @@ export function stripBarePrefixes(
210
224
  const fileHashSet = new Set(fileHashes);
211
225
  const stripped: { lineIndex: number; matched: boolean }[] = [];
212
226
  const contentLines = edit.content_lines.map((line, lineIndex) => {
213
- const match = line.match(HL_BARE_PREFIX_RE);
214
- if (!match) return line;
215
- stripped.push({ lineIndex, matched: fileHashSet.has(match[1]!) });
216
- return line.slice(match[0].length);
227
+ const result = stripRowPrefix(line);
228
+ if (result.kind !== "bare") return line;
229
+ stripped.push({ lineIndex, matched: fileHashSet.has(result.hash ?? "") });
230
+ return result.text;
217
231
  });
218
232
  if (stripped.length === 0) return edit;
219
233
  const locations = stripped
@@ -236,17 +250,10 @@ export function stripDiffPrefixes(
236
250
  ): HEdit {
237
251
  const stripped: number[] = [];
238
252
  const contentLines = edit.content_lines.map((line, lineIndex) => {
239
- const plus = line.match(HL_PREFIX_PLUS_RE);
240
- if (plus) {
241
- stripped.push(lineIndex);
242
- return line.slice(plus[0].length);
243
- }
244
- const minus = line.match(HL_PREFIX_MINUS_RE);
245
- if (minus) {
246
- stripped.push(lineIndex);
247
- return line.slice(minus[0].length);
248
- }
249
- return line;
253
+ const result = stripRowPrefix(line);
254
+ if (result.kind !== "plus" && result.kind !== "minus") return line;
255
+ stripped.push(lineIndex);
256
+ return result.text;
250
257
  });
251
258
  if (stripped.length === 0) return edit;
252
259
  const locations = stripped.map((i) => `replacement_lines line ${i + 1}`).join(", ");
@@ -473,13 +480,36 @@ export function valEdit(
473
480
  };
474
481
  }
475
482
 
483
+ export function resolveAnchorLine(
484
+ ref: Anchor,
485
+ fileLines: string[],
486
+ fileHashes: string[],
487
+ filePath?: string,
488
+ ): number {
489
+ const { resolved, mismatches } = valEdit(
490
+ { hash_bounds: [ref, ref], content_lines: [] },
491
+ fileLines,
492
+ fileHashes,
493
+ [],
494
+ undefined,
495
+ );
496
+ if (mismatches.length > 0 || !resolved) {
497
+ const feedback = fmtMismatchWithHashes(
498
+ mismatches,
499
+ fileLines,
500
+ fileHashes,
501
+ filePath,
502
+ );
503
+ throw new AnchorMismatchError(feedback.text, feedback.hashes);
504
+ }
505
+ return resolved.hash_bounds[0].line;
506
+ }
507
+
476
508
  export class RangeStaleError extends Error {
477
- readonly firstMismatchLine: number;
478
509
  readonly rangeHashes: string[];
479
- constructor(message: string, firstMismatchLine: number, rangeHashes: string[]) {
510
+ constructor(message: string, rangeHashes: string[]) {
480
511
  super(message);
481
512
  this.name = "RangeStaleError";
482
- this.firstMismatchLine = firstMismatchLine;
483
513
  this.rangeHashes = rangeHashes;
484
514
  }
485
515
  }
@@ -516,7 +546,7 @@ export function assertRangeServed(
516
546
  for (let line = startLine; line < startLine + shownLength; line++) {
517
547
  const hash = fileHashes[line - 1]!;
518
548
  shownHashes.push(hash);
519
- rows.push(`${hash}${HASH_SEP}${fileLines[line - 1]}`);
549
+ rows.push(fmtRow(hash, fileLines[line - 1]));
520
550
  }
521
551
  const location = filePath ? ` in ${filePath}` : "";
522
552
  const first = mismatchLines[0]!;
@@ -530,7 +560,7 @@ export function assertRangeServed(
530
560
  : "";
531
561
  const message =
532
562
  `[E_RANGE_STALE] ${mismatchText} what was shown. Nothing was modified. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
533
- throw new RangeStaleError(message, first, shownHashes);
563
+ throw new RangeStaleError(message, shownHashes);
534
564
  }
535
565
 
536
566
  export { warnUnicodeEsc };
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
+ }
@@ -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
+
@@ -1,8 +1,9 @@
1
+ import type { NEdit } from "./hashline";
1
2
  import type { ReplaceDetails } from "./replace";
2
3
  import { genDiff, genPatch } from "./replace-diff";
3
4
  import { visLines, clipLine } from "./utils";
4
5
 
5
- type TResult = {
6
+ export type TResult = {
6
7
  content: Array<{ type: "text"; text: string }>;
7
8
  isError?: boolean;
8
9
  details: ReplaceDetails;
@@ -27,14 +28,9 @@ export type RMeta = {
27
28
  removedLines: number;
28
29
  };
29
30
 
30
- type NEditEntry = {
31
- loc: string;
32
- currentContent: string;
33
- };
34
-
35
31
  export interface NoopInput {
36
32
  path: string;
37
- noopEdit: NEditEntry | undefined;
33
+ noopEdit: NEdit | undefined;
38
34
  snapshotId?: string;
39
35
  editMeta: RMeta;
40
36
  warnings: string[] | undefined;
@@ -89,7 +85,7 @@ function warnBlock(warnings: string[] | undefined): string {
89
85
  return warnings?.length ? `\n\nWarnings:\n${warnings.join("\n")}` : "";
90
86
  }
91
87
 
92
- export function buildNoop(input: NoopInput): TResult {
88
+ export function buildNoop(input: NoopInput, noopNoun = "Replacement"): TResult {
93
89
  const {
94
90
  path,
95
91
  noopEdit,
@@ -100,7 +96,7 @@ export function buildNoop(input: NoopInput): TResult {
100
96
  } = input;
101
97
 
102
98
  const noopDetailsText = noopEdit
103
- ? `Replacement for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
99
+ ? `${noopNoun} for ${noopEdit.loc} is identical to current content:\n ${noopEdit.loc}: ${clipLine(noopEdit.currentContent)}`
104
100
  : "The edit produced identical content.";
105
101
  const dedupNote =
106
102
  boundaryRemovedLines !== undefined && boundaryRemovedLines > 0
@@ -129,14 +125,14 @@ export function buildNoop(input: NoopInput): TResult {
129
125
  };
130
126
  }
131
127
 
132
- export function buildChanged(input: SuccessInput): TResult {
128
+ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
133
129
  const { path, result, warnings, snapshotId, originalNormalized, originalHashes, editMeta, resultHashes } = input;
134
130
  const resultLines = visLines(result);
135
131
  const diffResult = genDiff(originalNormalized, result, 1, resultHashes, originalHashes);
136
132
  const addedLines = editMeta.addedLines;
137
133
  const removedLines = editMeta.removedLines;
138
134
  const warningsBlock = warnBlock(warnings);
139
- const successPrefix = `Successfully replaced in ${path}.`;
135
+ const successPrefix = `Successfully ${verb} in ${path}.`;
140
136
  const lineSummary = addedLines > 0 || removedLines > 0
141
137
  ? ` Added ${addedLines} line(s), removed ${removedLines} line(s).`
142
138
  : "";