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.
@@ -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({
@@ -126,26 +126,15 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
126
126
  if (errCode(error) !== "ENOENT") throw error;
127
127
  }
128
128
 
129
- if (currentRaw === undefined) {
130
- await clearUndo(mutationTargetPath);
131
- return {
132
- content: [
133
- {
134
- type: "text",
135
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file no longer exists.`
136
- },
137
- ],
138
- isError: true,
139
- details: {},
140
- };
141
- }
142
- if (currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)) {
143
- await clearUndo(mutationTargetPath);
129
+ if (
130
+ currentRaw !== undefined &&
131
+ currentRaw !== undo.bom + restoreEndings(undo.resultContent, undo.originalEnding)
132
+ ) {
144
133
  return {
145
134
  content: [
146
135
  {
147
136
  type: "text",
148
- text: `[E_UNDO_STALE] Cannot undo last replace on ${path}: the file changed after the replace. Call read() to inspect the current state.`
137
+ text: `[E_UNDO_STALE] Cannot undo last change on ${path}: the file changed after the edit. The undo record is kept; once the file matches the edited state again, undo_last_change will succeed. Call read() to inspect the current state.`
149
138
  },
150
139
  ],
151
140
  isError: true,
@@ -153,20 +142,19 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
153
142
  };
154
143
  }
155
144
 
156
- const { text: currentStripped } = stripBOM(currentRaw);
157
- const currentNormalized = toLF(currentStripped);
145
+ await writeAtomic(
146
+ mutationTargetPath,
147
+ undo.bom + restoreEndings(undo.content, undo.originalEnding),
148
+ );
149
+
150
+ const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
158
151
  const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
159
- const diffResult = genDiff(undo.content, currentNormalized, 0, undefined, undo.hashes);
152
+ const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes);
160
153
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
161
154
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
162
155
  const restoredRange = changedRange(currentNormalized, undo.content);
163
156
  const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
164
157
 
165
- await writeAtomic(
166
- mutationTargetPath,
167
- undo.bom + restoreEndings(undo.content, undo.originalEnding),
168
- );
169
-
170
158
  try {
171
159
  const store = await loadHashStore();
172
160
  upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
@@ -178,8 +166,11 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
178
166
  await clearUndo(mutationTargetPath);
179
167
 
180
168
  const parts: string[] = [
181
- `Undone last replace on ${path}.`,
169
+ `Undone last change on ${path}.`,
182
170
  ];
171
+ if (currentRaw === undefined) {
172
+ parts.push("The file was deleted; restored it from undo history.");
173
+ }
183
174
  if (linesAddedByReplace > 0 || linesRemovedByReplace > 0) {
184
175
  parts.push(
185
176
  `Removed ${linesAddedByReplace} line(s), restored ${linesRemovedByReplace} line(s).`,
package/src/replace.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { Markdown, Text } from "@earendil-works/pi-tui";
2
1
  import type {
3
2
  ExtensionAPI,
4
3
  ToolDefinition,
@@ -8,13 +7,12 @@ import { Type } from "typebox";
8
7
  import { constants } from "fs";
9
8
  import {
10
9
  genDiff,
11
- restoreEndings,
12
10
  type LineEnding,
13
11
  } from "./replace-diff";
14
- import { readNormFile, safeSnapId } from "./file-reader";
12
+ import { readNormFile, type NormFile } from "./file-reader";
15
13
  import { normReq } from "./replace-normalize";
16
14
  import { isRec, rejectUnknownFields, abortIf, makePrepareArguments } from "./utils";
17
- import { resolveTarget, writeAtomic } from "./fs-write";
15
+ import { resolveTarget } from "./fs-write";
18
16
  import { applyEdit,
19
17
  lineHashes,
20
18
  resEdit,
@@ -26,28 +24,18 @@ import { applyEdit,
26
24
  type NEdit,
27
25
  } from "./hashline";
28
26
  import { toCwd } from "./paths";
27
+ import type { RMetrics } from "./replace-response";
29
28
  import {
30
- buildChanged,
31
- buildNoop,
32
- type RMeta,
33
- type RMetrics,
34
- } from "./replace-response";
35
- import {
36
- buildAppliedText,
37
- mkMdTheme,
38
- fmtCall,
39
- fmtResultMd,
40
- getPreviewInput,
41
- getResultText,
42
- isApplied,
29
+ makeRenderCall,
30
+ renderEditResult,
43
31
  type RPreview,
44
32
  type RRState,
45
33
  } from "./replace-render";
46
34
  import { loadP, loadGuide } from "./prompts";
47
- import { saveUndo } from "./replace-undo";
48
- import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
49
- import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
35
+ import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
36
+ import { getServed, recordServedSafe } from "./served";
50
37
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
38
+ import { commitEdit } from "./commit";
51
39
 
52
40
  const replacementLinesSchema = Type.Array(
53
41
  Type.String({
@@ -93,7 +81,7 @@ export type ReplaceDetails = {
93
81
  metrics?: RMetrics;
94
82
  };
95
83
 
96
- interface PipelineResult {
84
+ export interface PipelineResult {
97
85
  path: string;
98
86
  originalNormalized: string;
99
87
  result: string;
@@ -112,8 +100,6 @@ interface PipelineResult {
112
100
  boundaryRemovedLines: number;
113
101
  }
114
102
 
115
- const PREVIEW_DEBOUNCE_MS = 150;
116
-
117
103
  const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
118
104
 
119
105
  export function assertReq(
@@ -162,7 +148,7 @@ async function resolveMissingPath(
162
148
  } catch {
163
149
  return undefined;
164
150
  }
165
- const matches = findSnapshotPaths(store, hashes);
151
+ const matches = [...new Set([...findSnapshotPaths(store, hashes), ...findServedPaths(store, hashes)])];
166
152
  if (matches.length === 1) {
167
153
  return {
168
154
  path: matches[0]!,
@@ -183,6 +169,7 @@ export interface ExecPipelineOptions {
183
169
  store?: HashStore;
184
170
  noPersist?: boolean;
185
171
  skipBoundaryDedup?: boolean;
172
+ preloadedNorm?: NormFile;
186
173
  }
187
174
 
188
175
  function collectRemovedHashes(
@@ -243,7 +230,7 @@ export async function execPipeline(
243
230
 
244
231
  const hashStore = options?.store ?? await loadHashStore();
245
232
  const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
246
- path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
233
+ path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
247
234
  );
248
235
 
249
236
  const served = await getServed(hashStore, absolutePath);
@@ -338,21 +325,6 @@ type ToolDef = ToolDefinition<
338
325
  RRState
339
326
  > & { renderShell?: "default" | "self" };
340
327
 
341
- export function reuseText(context: any, content: string): Text {
342
- const t = context.lastComponent instanceof Text
343
- ? context.lastComponent
344
- : new Text("", 0, 0);
345
- t.setText(content);
346
- return t;
347
- }
348
-
349
- export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
350
- const m = context.lastComponent instanceof Markdown
351
- ? context.lastComponent
352
- : new Markdown("", 0, 0, mkMdTheme(theme));
353
- m.setText(content);
354
- return m;
355
- }
356
328
 
357
329
  export function buildToolDef(): ToolDef {
358
330
  const E_DESC = loadP("../prompts/replace.md");
@@ -369,107 +341,17 @@ export function buildToolDef(): ToolDef {
369
341
  promptGuidelines: E_GUIDE,
370
342
  prepareArguments: makePrepareArguments(),
371
343
  renderShell: "default",
372
- renderCall(args, theme, context) {
373
- const previewInput = getPreviewInput(args);
374
- const cancelPendingPreview = () => {
375
- if (context.state.previewTimer) {
376
- clearTimeout(context.state.previewTimer);
377
- context.state.previewTimer = undefined;
378
- }
379
- };
380
- if (context.executionStarted) {
381
- cancelPendingPreview();
382
- context.state.argsKey = undefined;
383
- context.state.preview = undefined;
384
- context.state.previewGeneration =
385
- (context.state.previewGeneration ?? 0) + 1;
386
- } else if (!context.argsComplete || !previewInput) {
387
- cancelPendingPreview();
388
- context.state.argsKey = undefined;
389
- context.state.preview = undefined;
390
- context.state.previewGeneration =
391
- (context.state.previewGeneration ?? 0) + 1;
392
- } else {
393
- const argsKey = JSON.stringify(previewInput);
394
- if (context.state.argsKey !== argsKey) {
395
- cancelPendingPreview();
396
- context.state.argsKey = argsKey;
397
- context.state.preview = undefined;
398
- const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
399
- context.state.previewGeneration = previewGeneration;
400
- context.state.previewTimer = setTimeout(() => {
401
- context.state.previewTimer = undefined;
402
- compPreview(args, context.cwd)
403
- .then((preview) => {
404
- if (
405
- context.state.argsKey === argsKey &&
406
- context.state.previewGeneration === previewGeneration
407
- ) {
408
- context.state.preview = preview;
409
- context.invalidate();
410
- }
411
- })
412
- .catch((err: unknown) => {
413
- if (
414
- context.state.argsKey === argsKey &&
415
- context.state.previewGeneration === previewGeneration
416
- ) {
417
- context.state.preview = {
418
- error: err instanceof Error ? err.message : String(err),
419
- };
420
- context.invalidate();
421
- }
422
- });
423
- }, PREVIEW_DEBOUNCE_MS);
424
- }
425
- }
426
- const text =
427
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
428
- text.setText(
429
- fmtCall(
430
- getPreviewInput(args) ?? undefined,
431
- context.state as RRState,
432
- context.expanded,
433
- theme,
434
- ),
435
- );
436
- return text;
437
- },
438
-
344
+ renderCall: makeRenderCall(compPreview),
439
345
  renderResult(result, { isPartial }, theme, context) {
440
- if (isPartial) {
441
- return reuseText(context, theme.fg("warning", "Editing..."));
442
- }
443
-
444
- const typedResult = result as {
445
- content?: Array<{ type: string; text?: string }>;
446
- details?: ReplaceDetails;
447
- };
448
- const renderedText = getResultText(typedResult);
449
-
450
- const renderState = context.state as RRState | undefined;
451
- if (renderState) {
452
- if (renderState.previewTimer) {
453
- clearTimeout(renderState.previewTimer);
454
- renderState.previewTimer = undefined;
455
- }
456
- renderState.preview = undefined;
457
- renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
458
- }
459
-
460
- if (context.isError) {
461
- return renderedText
462
- ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
463
- : new Text("", 0, 0);
464
- }
465
-
466
- if (isApplied(typedResult.details)) {
467
- const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
468
- return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
469
- }
470
-
471
- if (!renderedText) return new Text("", 0, 0);
472
- return reuseMarkdown(context, fmtResultMd(renderedText), theme);
346
+ return renderEditResult(
347
+ result as {
348
+ content?: Array<{ type: string; text?: string }>;
349
+ details?: ReplaceDetails;
350
+ },
351
+ isPartial,
352
+ theme,
353
+ context,
354
+ );
473
355
  },
474
356
 
475
357
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -488,113 +370,24 @@ export function buildToolDef(): ToolDef {
488
370
  const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
489
371
  return withFileMutationQueue(mutationTargetPath, async () => {
490
372
  abortIf(signal);
491
-
492
- const {
493
- originalNormalized,
494
- originalHashes,
495
- result,
496
- bom,
497
- originalEnding,
498
- hadUtf8DecodeErrors,
499
- warnings,
500
- noopEdit,
501
- firstChangedLine,
502
- lastChangedLine,
503
- resultHashes,
504
- hadBoundaryDedup,
505
- boundaryRemovedLines,
506
- totalAddedLines,
507
- totalRemovedLines,
508
- } = await execPipeline(
373
+ const pipe = await execPipeline(
509
374
  normalizedParams,
510
375
  ctx.cwd,
511
376
  { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
512
377
  );
513
-
514
- if (resolution) {
515
- warnings.unshift(resolution.warning);
516
- }
517
- if (boundaryBypass && originalNormalized !== result) {
518
- warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on.");
519
- }
520
-
521
- const editsAttempted = 1;
522
- if (originalNormalized === result) {
523
- const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
524
- if (hadBoundaryDedup) {
525
- markBoundaryNoop(mutationTargetPath, noopPayload);
526
- }
527
- return buildNoop({
528
- path,
529
- noopEdit,
530
- snapshotId: noopSnapshotId,
531
- editMeta: {
532
- editsAttempted,
533
- noopEditsCount: noopEdit ? 1 : 0,
534
- addedLines: 0,
535
- removedLines: 0,
536
- },
537
- warnings,
538
- boundaryRemovedLines,
539
- });
540
- }
541
-
542
- if (hadUtf8DecodeErrors) {
543
- warnings.push(
544
- "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
545
- );
546
- }
547
-
548
- abortIf(signal);
549
- const undo = await saveUndo(mutationTargetPath, {
550
- content: originalNormalized,
551
- bom,
552
- originalEnding,
553
- hashes: originalHashes,
554
- resultContent: result,
555
- });
556
- if (!undo.persisted) {
557
- throw new Error(
558
- `[E_UNDO_UNAVAILABLE] Could not persist undo history; the edit was not applied and ${path} is unchanged.`
559
- );
560
- }
561
- try {
562
- abortIf(signal);
563
- await writeAtomic(
564
- absolutePath,
565
- bom + restoreEndings(result, originalEnding),
566
- );
567
- } catch (error) {
568
- await undo.restore();
569
- throw error;
570
- }
571
- clearBoundaryBypass(mutationTargetPath);
572
- const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
573
-
574
- const editMeta: RMeta = {
575
- editsAttempted,
576
- noopEditsCount: noopEdit ? 1 : 0,
577
- firstChangedLine,
578
- lastChangedLine,
579
- addedLines: totalAddedLines,
580
- removedLines: totalRemovedLines,
581
- };
582
-
583
- const successInput = {
378
+ const appliedWarnings = boundaryBypass
379
+ ? ["[E_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
380
+ : [];
381
+ return commitEdit(pipe, {
584
382
  path,
585
- originalNormalized,
586
- originalHashes,
587
- result,
588
- resultHashes,
589
- warnings,
590
- snapshotId: updatedSnapshotId,
591
- editMeta,
592
- };
593
- const changed = buildChanged(successInput);
594
- if (changed.details.diff) {
595
- await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(resultHashes));
596
- }
597
- return changed;
383
+ absolutePath,
384
+ mutationTargetPath,
385
+ signal,
386
+ prefixWarnings: resolution ? [resolution.warning] : [],
387
+ appliedWarnings,
388
+ onApplied: () => clearBoundaryBypass(mutationTargetPath),
389
+ onNoopDedup: () => markBoundaryNoop(mutationTargetPath, noopPayload),
390
+ });
598
391
  });
599
392
  },
600
393
  };
package/src/served.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { loadHashStore, parseHashList, type HashStore } from "./hash-store";
1
+ import { loadHashStore, parseStoredHashes, type HashStore } from "./hash-store";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
 
4
4
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
@@ -14,8 +14,7 @@ export function servedHashesFromDiff(diff: string): string[] {
14
14
 
15
15
  export function getServed(store: HashStore, path: string): Set<string> | undefined {
16
16
  const row = store.stmts.servedGet(path);
17
- if (!row) return undefined;
18
- const parsed = parseHashList(row.hashes as string, () => store.stmts.servedDelete(path));
17
+ const parsed = parseStoredHashes(row, () => store.stmts.servedDelete(path));
19
18
  if (!parsed) return undefined;
20
19
  return new Set(parsed);
21
20
  }
@@ -1,2 +0,0 @@
1
- - `undo_last_replace`: reverts only the most recent replace on the file: any write to the file clears the undo history, so call it immediately after a bad replace. A replace is bad when its post-edit diff shows `-HASH│` rows for lines you meant to keep (a closing brace, import, or declaration).
2
- - `undo_last_replace`: when auto-read shows the post-edit diff, its `+HASH│` and ` HASH│` rows are the fresh anchors for the restored file, so follow-up edits can anchor on the diff without re-reading.
@@ -1 +0,0 @@
1
- Undo the last replace on a file
@@ -1 +0,0 @@
1
- Undo the last replace on a file, reverting it to its previous state. Use when a replace produced incorrect results (e.g., wrong content, duplicated lines, broken syntax).