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/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,33 +24,24 @@ 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";
34
+ export { reuseText, reuseMarkdown } from "./replace-render";
46
35
  import { loadP, loadGuide } from "./prompts";
47
- import { saveUndo } from "./replace-undo";
48
36
  import { loadHashStore, findSnapshotPaths, type HashStore } from "./hash-store";
49
- import { getServed, recordServedSafe, recordServedDiffSafe } from "./served";
37
+ import { getServed, recordServedSafe } from "./served";
50
38
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
39
+ import { commitEdit } from "./commit";
51
40
 
52
41
  const replacementLinesSchema = Type.Array(
53
42
  Type.String({
54
43
  description:
55
- "One replacement line. Each element is exactly one line; do not embed \\n inside an element — use separate elements.",
44
+ "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
56
45
  }),
57
46
  {
58
47
  description:
@@ -61,16 +50,16 @@ const replacementLinesSchema = Type.Array(
61
50
  );
62
51
 
63
52
  const removeFromSchema = Type.String({
64
- description: "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. Marks the FIRST line to remove (inclusive)",
53
+ description: "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. Marks the FIRST line to remove (inclusive)",
65
54
  });
66
55
 
67
56
  const removeToSchema = Type.String({
68
- description: "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. Marks the LAST line to remove (inclusive)",
57
+ description: "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. Marks the LAST line to remove (inclusive)",
69
58
  });
70
59
 
71
60
  export const editToolSchema = Type.Object(
72
61
  {
73
- path: Type.Optional(Type.String({ description: "Path to edit. Required — always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
62
+ path: Type.Optional(Type.String({ description: "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
74
63
  remove_from: removeFromSchema,
75
64
  remove_to: removeToSchema,
76
65
  replacement_lines: replacementLinesSchema,
@@ -93,7 +82,7 @@ export type ReplaceDetails = {
93
82
  metrics?: RMetrics;
94
83
  };
95
84
 
96
- interface PipelineResult {
85
+ export interface PipelineResult {
97
86
  path: string;
98
87
  originalNormalized: string;
99
88
  result: string;
@@ -109,10 +98,9 @@ interface PipelineResult {
109
98
  totalAddedLines: number;
110
99
  totalRemovedLines: number;
111
100
  hadBoundaryDedup: boolean;
101
+ boundaryRemovedLines: number;
112
102
  }
113
103
 
114
- const PREVIEW_DEBOUNCE_MS = 150;
115
-
116
104
  const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
117
105
 
118
106
  export function assertReq(
@@ -135,7 +123,7 @@ export function assertReq(
135
123
  request.replacement_lines.some((line) => typeof line !== "string")
136
124
  ) {
137
125
  throw new Error(
138
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" at the top level. replacement_lines must be an array of strings, one element per line (use [] to delete).',
126
+ '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
139
127
  );
140
128
  }
141
129
  }
@@ -165,12 +153,12 @@ async function resolveMissingPath(
165
153
  if (matches.length === 1) {
166
154
  return {
167
155
  path: matches[0]!,
168
- warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]} — the only file whose stored hashes contain both anchors.`,
156
+ warning: `[E_BAD_SHAPE] Autocorrected: missing "path" resolved to ${matches[0]}.`,
169
157
  };
170
158
  }
171
159
  if (matches.length > 1) {
172
160
  throw new Error(
173
- `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}. Include the intended path.`,
161
+ `[E_BAD_SHAPE] Edit request requires a non-empty "path" string; the anchors match multiple known files: ${matches.join(", ")}.`,
174
162
  );
175
163
  }
176
164
  return undefined;
@@ -182,6 +170,7 @@ export interface ExecPipelineOptions {
182
170
  store?: HashStore;
183
171
  noPersist?: boolean;
184
172
  skipBoundaryDedup?: boolean;
173
+ preloadedNorm?: NormFile;
185
174
  }
186
175
 
187
176
  function collectRemovedHashes(
@@ -242,7 +231,7 @@ export async function execPipeline(
242
231
 
243
232
  const hashStore = options?.store ?? await loadHashStore();
244
233
  const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
245
- path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist },
234
+ path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
246
235
  );
247
236
 
248
237
  const served = await getServed(hashStore, absolutePath);
@@ -303,6 +292,7 @@ export async function execPipeline(
303
292
  totalAddedLines,
304
293
  totalRemovedLines,
305
294
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
295
+ boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
306
296
  };
307
297
  }
308
298
 
@@ -336,21 +326,6 @@ type ToolDef = ToolDefinition<
336
326
  RRState
337
327
  > & { renderShell?: "default" | "self" };
338
328
 
339
- export function reuseText(context: any, content: string): Text {
340
- const t = context.lastComponent instanceof Text
341
- ? context.lastComponent
342
- : new Text("", 0, 0);
343
- t.setText(content);
344
- return t;
345
- }
346
-
347
- export function reuseMarkdown(context: any, content: string, theme: any): Markdown {
348
- const m = context.lastComponent instanceof Markdown
349
- ? context.lastComponent
350
- : new Markdown("", 0, 0, mkMdTheme(theme));
351
- m.setText(content);
352
- return m;
353
- }
354
329
 
355
330
  export function buildToolDef(): ToolDef {
356
331
  const E_DESC = loadP("../prompts/replace.md");
@@ -367,107 +342,17 @@ export function buildToolDef(): ToolDef {
367
342
  promptGuidelines: E_GUIDE,
368
343
  prepareArguments: makePrepareArguments(),
369
344
  renderShell: "default",
370
- renderCall(args, theme, context) {
371
- const previewInput = getPreviewInput(args);
372
- const cancelPendingPreview = () => {
373
- if (context.state.previewTimer) {
374
- clearTimeout(context.state.previewTimer);
375
- context.state.previewTimer = undefined;
376
- }
377
- };
378
- if (context.executionStarted) {
379
- cancelPendingPreview();
380
- context.state.argsKey = undefined;
381
- context.state.preview = undefined;
382
- context.state.previewGeneration =
383
- (context.state.previewGeneration ?? 0) + 1;
384
- } else if (!context.argsComplete || !previewInput) {
385
- cancelPendingPreview();
386
- context.state.argsKey = undefined;
387
- context.state.preview = undefined;
388
- context.state.previewGeneration =
389
- (context.state.previewGeneration ?? 0) + 1;
390
- } else {
391
- const argsKey = JSON.stringify(previewInput);
392
- if (context.state.argsKey !== argsKey) {
393
- cancelPendingPreview();
394
- context.state.argsKey = argsKey;
395
- context.state.preview = undefined;
396
- const previewGeneration = (context.state.previewGeneration ?? 0) + 1;
397
- context.state.previewGeneration = previewGeneration;
398
- context.state.previewTimer = setTimeout(() => {
399
- context.state.previewTimer = undefined;
400
- compPreview(args, context.cwd)
401
- .then((preview) => {
402
- if (
403
- context.state.argsKey === argsKey &&
404
- context.state.previewGeneration === previewGeneration
405
- ) {
406
- context.state.preview = preview;
407
- context.invalidate();
408
- }
409
- })
410
- .catch((err: unknown) => {
411
- if (
412
- context.state.argsKey === argsKey &&
413
- context.state.previewGeneration === previewGeneration
414
- ) {
415
- context.state.preview = {
416
- error: err instanceof Error ? err.message : String(err),
417
- };
418
- context.invalidate();
419
- }
420
- });
421
- }, PREVIEW_DEBOUNCE_MS);
422
- }
423
- }
424
- const text =
425
- (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
426
- text.setText(
427
- fmtCall(
428
- getPreviewInput(args) ?? undefined,
429
- context.state as RRState,
430
- context.expanded,
431
- theme,
432
- ),
433
- );
434
- return text;
435
- },
436
-
345
+ renderCall: makeRenderCall(compPreview),
437
346
  renderResult(result, { isPartial }, theme, context) {
438
- if (isPartial) {
439
- return reuseText(context, theme.fg("warning", "Editing..."));
440
- }
441
-
442
- const typedResult = result as {
443
- content?: Array<{ type: string; text?: string }>;
444
- details?: ReplaceDetails;
445
- };
446
- const renderedText = getResultText(typedResult);
447
-
448
- const renderState = context.state as RRState | undefined;
449
- if (renderState) {
450
- if (renderState.previewTimer) {
451
- clearTimeout(renderState.previewTimer);
452
- renderState.previewTimer = undefined;
453
- }
454
- renderState.preview = undefined;
455
- renderState.previewGeneration = (renderState.previewGeneration ?? 0) + 1;
456
- }
457
-
458
- if (context.isError) {
459
- return renderedText
460
- ? reuseText(context, `\n${theme.fg("error", renderedText)}`)
461
- : new Text("", 0, 0);
462
- }
463
-
464
- if (isApplied(typedResult.details)) {
465
- const appliedText = buildAppliedText(renderedText, typedResult.details, theme);
466
- return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
467
- }
468
-
469
- if (!renderedText) return new Text("", 0, 0);
470
- return reuseMarkdown(context, fmtResultMd(renderedText), theme);
347
+ return renderEditResult(
348
+ result as {
349
+ content?: Array<{ type: string; text?: string }>;
350
+ details?: ReplaceDetails;
351
+ },
352
+ isPartial,
353
+ theme,
354
+ context,
355
+ );
471
356
  },
472
357
 
473
358
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -486,111 +371,24 @@ export function buildToolDef(): ToolDef {
486
371
  const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
487
372
  return withFileMutationQueue(mutationTargetPath, async () => {
488
373
  abortIf(signal);
489
-
490
- const {
491
- originalNormalized,
492
- originalHashes,
493
- result,
494
- bom,
495
- originalEnding,
496
- hadUtf8DecodeErrors,
497
- warnings,
498
- noopEdit,
499
- firstChangedLine,
500
- lastChangedLine,
501
- resultHashes,
502
- hadBoundaryDedup,
503
- totalAddedLines,
504
- totalRemovedLines,
505
- } = await execPipeline(
374
+ const pipe = await execPipeline(
506
375
  normalizedParams,
507
376
  ctx.cwd,
508
377
  { accessMode: constants.R_OK | constants.W_OK, signal, skipBoundaryDedup: boundaryBypass },
509
378
  );
510
-
511
- if (resolution) {
512
- warnings.unshift(resolution.warning);
513
- }
514
- if (boundaryBypass && originalNormalized !== result) {
515
- warnings.push("[E_BOUNDARY_BYPASS] Boundary dedup was off for this call. Boundary dedup is now restored.");
516
- }
517
-
518
- const editsAttempted = 1;
519
- if (originalNormalized === result) {
520
- const noopSnapshotId = await safeSnapId(absolutePath, "noop edit");
521
- if (hadBoundaryDedup) {
522
- markBoundaryNoop(mutationTargetPath, noopPayload);
523
- }
524
- return buildNoop({
525
- path,
526
- noopEdit,
527
- snapshotId: noopSnapshotId,
528
- editMeta: {
529
- editsAttempted,
530
- noopEditsCount: noopEdit ? 1 : 0,
531
- addedLines: 0,
532
- removedLines: 0,
533
- },
534
- warnings,
535
- });
536
- }
537
-
538
- if (hadUtf8DecodeErrors) {
539
- warnings.push(
540
- "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
541
- );
542
- }
543
-
544
- abortIf(signal);
545
- const undo = await saveUndo(mutationTargetPath, {
546
- content: originalNormalized,
547
- bom,
548
- originalEnding,
549
- hashes: originalHashes,
550
- resultContent: result,
551
- });
552
- if (!undo.persisted) {
553
- throw new Error(
554
- `[E_UNDO_UNAVAILABLE] Cannot persist undo history to the hash store; the edit was NOT applied and ${path} is unchanged. Retry the replace, or use write if the store cannot be recovered.`
555
- );
556
- }
557
- try {
558
- abortIf(signal);
559
- await writeAtomic(
560
- absolutePath,
561
- bom + restoreEndings(result, originalEnding),
562
- );
563
- } catch (error) {
564
- await undo.restore();
565
- throw error;
566
- }
567
- clearBoundaryBypass(mutationTargetPath);
568
- const updatedSnapshotId = await safeSnapId(absolutePath, "post-edit");
569
-
570
- const editMeta: RMeta = {
571
- editsAttempted,
572
- noopEditsCount: noopEdit ? 1 : 0,
573
- firstChangedLine,
574
- lastChangedLine,
575
- addedLines: totalAddedLines,
576
- removedLines: totalRemovedLines,
577
- };
578
-
579
- const successInput = {
379
+ const appliedWarnings = boundaryBypass
380
+ ? ["[E_BOUNDARY_BYPASS] Boundary dedup was off for this call and is back on."]
381
+ : [];
382
+ return commitEdit(pipe, {
580
383
  path,
581
- originalNormalized,
582
- originalHashes,
583
- result,
584
- resultHashes,
585
- warnings,
586
- snapshotId: updatedSnapshotId,
587
- editMeta,
588
- };
589
- const changed = buildChanged(successInput);
590
- if (changed.details.diff) {
591
- await recordServedDiffSafe(mutationTargetPath, changed.details.diff, "post-edit diff", new Set(resultHashes));
592
- }
593
- return changed;
384
+ absolutePath,
385
+ mutationTargetPath,
386
+ signal,
387
+ prefixWarnings: resolution ? [resolution.warning] : [],
388
+ appliedWarnings,
389
+ onApplied: () => clearBoundaryBypass(mutationTargetPath),
390
+ onNoopDedup: () => markBoundaryNoop(mutationTargetPath, noopPayload),
391
+ });
594
392
  });
595
393
  },
596
394
  };
package/src/validation.ts CHANGED
@@ -38,7 +38,7 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
38
38
  }
39
39
  if (file.kind === "too_large") {
40
40
  throw new Error(
41
- `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
41
+ `[E_FILE_TOO_LARGE] File is too large: ${path} (${file.description}). For very large files, use write.`,
42
42
  );
43
43
  }
44
44
  }
@@ -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).