@heyhuynhgiabuu/pi-diff 0.6.4 → 0.6.6

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.
Files changed (54) hide show
  1. package/README.md +37 -181
  2. package/dist/edit-guard.d.ts +2 -0
  3. package/dist/edit-guard.d.ts.map +1 -0
  4. package/dist/edit-guard.js +34 -0
  5. package/dist/edit-guard.js.map +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +49 -160
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -49
  10. package/dist/cli.d.ts +0 -3
  11. package/dist/cli.d.ts.map +0 -1
  12. package/dist/cli.js +0 -53
  13. package/dist/cli.js.map +0 -1
  14. package/dist/core/resolve-lines.d.ts +0 -45
  15. package/dist/core/resolve-lines.d.ts.map +0 -1
  16. package/dist/core/resolve-lines.js +0 -204
  17. package/dist/core/resolve-lines.js.map +0 -1
  18. package/dist/review/command.d.ts +0 -3
  19. package/dist/review/command.d.ts.map +0 -1
  20. package/dist/review/command.js +0 -492
  21. package/dist/review/command.js.map +0 -1
  22. package/dist/review/export.d.ts +0 -7
  23. package/dist/review/export.d.ts.map +0 -1
  24. package/dist/review/export.js +0 -74
  25. package/dist/review/export.js.map +0 -1
  26. package/dist/review/file-preview.d.ts +0 -9
  27. package/dist/review/file-preview.d.ts.map +0 -1
  28. package/dist/review/file-preview.js +0 -225
  29. package/dist/review/file-preview.js.map +0 -1
  30. package/dist/review/hunk-bridge.d.ts +0 -54
  31. package/dist/review/hunk-bridge.d.ts.map +0 -1
  32. package/dist/review/hunk-bridge.js +0 -105
  33. package/dist/review/hunk-bridge.js.map +0 -1
  34. package/dist/review/interactive.d.ts +0 -27
  35. package/dist/review/interactive.d.ts.map +0 -1
  36. package/dist/review/interactive.js +0 -137
  37. package/dist/review/interactive.js.map +0 -1
  38. package/dist/review/model.d.ts +0 -33
  39. package/dist/review/model.d.ts.map +0 -1
  40. package/dist/review/model.js +0 -197
  41. package/dist/review/model.js.map +0 -1
  42. package/dist/review/prompt.d.ts +0 -4
  43. package/dist/review/prompt.d.ts.map +0 -1
  44. package/dist/review/prompt.js +0 -40
  45. package/dist/review/prompt.js.map +0 -1
  46. package/dist/review/session.d.ts +0 -69
  47. package/dist/review/session.d.ts.map +0 -1
  48. package/dist/review/session.js +0 -168
  49. package/dist/review/session.js.map +0 -1
  50. package/dist/review/tui.d.ts +0 -57
  51. package/dist/review/tui.d.ts.map +0 -1
  52. package/dist/review/tui.js +0 -482
  53. package/dist/review/tui.js.map +0 -1
  54. package/prompts/review-diff-agent.md +0 -19
package/README.md CHANGED
@@ -25,8 +25,8 @@ A [pi](https://pi.dev) extension that replaces the default `write` and `edit` to
25
25
  - **Adaptive layout** — auto-detects terminal width; wraps intelligently on wide terminals, truncates on narrow ones
26
26
  - **LRU cache** — singleton Shiki highlighter with 192-entry cache for fast re-renders
27
27
  - **Large diff fallback** — gracefully degrades (skips highlighting, still shows diff structure) for files > 80k chars
28
- - **Review context export** — `pi-diff-review` exports Git changes as agent-ready markdown for Warp-style local code review
29
28
  - **Fully customizable** — every color and threshold is overridable via environment variables
29
+ - **Edit guard** — blocks `edit` tool calls whose `oldText` is no longer in the target file, forcing the model to re-read before retrying (prevents stale-`oldText` retry loops)
30
30
 
31
31
  ## Install
32
32
 
@@ -42,170 +42,6 @@ Or load directly for development:
42
42
  pi -e ./src/index.ts
43
43
  ```
44
44
 
45
- ## Git Review in Pi TUI
46
-
47
- pi-diff provides two review surfaces:
48
-
49
- 1. **`/review-diff`** — the primary interactive local-review command. If [Hunk](https://github.com/modem-dev/hunk) is installed, pi-diff opens Hunk's review-first TUI.
50
- 2. **`review_git_diff`** — a read-only markdown/tool path for agent context and scripted review.
51
-
52
- ### Install Hunk for `/review-diff`
53
-
54
- `/review-diff` works best with Hunk installed:
55
-
56
- ```bash
57
- # npm
58
- npm i -g hunkdiff
59
-
60
- # or Homebrew
61
- brew install modem-dev/tap/hunk
62
- ```
63
-
64
- Verify Hunk is available:
65
-
66
- ```bash
67
- hunk --version
68
- ```
69
-
70
- If Hunk is not installed, pi-diff falls back to its built-in legacy review overlay and shows a warning.
71
-
72
- ### `/review-diff` — Hunk-backed interactive review
73
-
74
- Run inside Pi:
75
-
76
- ```text
77
- /review-diff
78
- /review-diff --base main
79
- /review-diff working-tree
80
- /review-diff feature/base-branch
81
- ```
82
-
83
- What happens:
84
-
85
- ```text
86
- /review-diff
87
- → pi-diff reads the current Git diff
88
- → pipes the patch to `hunk patch -`
89
- → Hunk opens its sidebar + diff review TUI
90
- → when you exit Hunk, pi-diff returns to Pi
91
- ```
92
-
93
- You can test the same Hunk view outside Pi:
94
-
95
- ```bash
96
- git diff | hunk patch -
97
- # or
98
- hunk diff
99
- ```
100
-
101
- Hunk is responsible for the review UI: multi-file sidebar, split/stack layouts, keyboard navigation, mouse support, and agent-note/comment workflows. pi-diff intentionally does **not** maintain a second in-memory comment system.
102
-
103
- ### Hunk configuration
104
-
105
- Hunk reads config from:
106
-
107
- ```text
108
- ~/.config/hunk/config.toml # global defaults
109
- .hunk/config.toml # repo-local override
110
- ```
111
-
112
- Recommended global starter config:
113
-
114
- ```toml
115
- # ~/.config/hunk/config.toml
116
-
117
- theme = "midnight" # graphite, midnight, paper, ember, catppuccin-latte, catppuccin-mocha, custom
118
- mode = "auto" # auto, split, stack
119
- vcs = "git"
120
- watch = false
121
-
122
- line_numbers = true
123
- wrap_lines = false
124
- agent_notes = true
125
- exclude_untracked = false
126
- ```
127
-
128
- Example repo-local override:
129
-
130
- ```bash
131
- mkdir -p .hunk
132
- $EDITOR .hunk/config.toml
133
- ```
134
-
135
- ```toml
136
- # .hunk/config.toml
137
-
138
- theme = "graphite"
139
- mode = "split"
140
- line_numbers = true
141
- wrap_lines = false
142
- agent_notes = true
143
- ```
144
-
145
- Hunk also supports custom themes:
146
-
147
- ```toml
148
- theme = "custom"
149
-
150
- [custom_theme]
151
- base = "graphite"
152
- label = "Pi Diff"
153
- accent = "#7fd1ff"
154
- panel = "#10161d"
155
- noteBorder = "#c49bff"
156
-
157
- [custom_theme.syntax]
158
- keyword = "#8ed4ff"
159
- string = "#c7b4ff"
160
- comment = "#6e85a7"
161
- ```
162
-
163
- All custom theme colors must be `#rrggbb` hex values.
164
-
165
- ### `review_git_diff` — read-only markdown review tool
166
-
167
- `review_git_diff` remains available for agent/tool use and for non-destructive markdown exports.
168
-
169
- ```ts
170
- // Open the read-only review markdown for working-tree changes, including untracked files
171
- review_git_diff({});
172
-
173
- // Review branch changes as main...HEAD
174
- review_git_diff({ base: "main" });
175
-
176
- // Focus a file from the changed-file list
177
- review_git_diff({ file: "src/index.ts" });
178
-
179
- // Focus a hunk ID shown by the panel
180
- review_git_diff({ file: "src/index.ts", hunkId: "src/index.ts:10:12" });
181
-
182
- // Return the older full markdown export with raw unified diff included
183
- review_git_diff({ base: "main", includeRawDiff: true });
184
- ```
185
-
186
- - No `base` means working-tree review, including untracked files.
187
- - `base: "main"` means `main...HEAD` branch review.
188
- - `includeRawDiff` returns the older full review export with raw unified diff.
189
- - `maxLinesPerHunk`, `maxFiles`, and `maxHunks` cap large markdown reviews.
190
- - The tool path is intentionally non-destructive: it never reverts, stages, commits, discards, or edits files.
191
-
192
- ## Review Export CLI
193
-
194
- `pi-diff-review` exports the same Git review context as markdown for use outside Pi TUI.
195
-
196
- ```bash
197
- # Review uncommitted working-tree changes
198
- pi-diff-review
199
-
200
- # Review branch changes like a pull request
201
- pi-diff-review --base main
202
-
203
- # Include the raw git diff after the structured summary
204
- pi-diff-review --raw
205
- ```
206
-
207
- The export includes changed files, hunk headers, changed-line numbers, and review instructions focused on correctness, regressions, security, tests, and maintainability.
208
-
209
45
  ## How It Works
210
46
 
211
47
  pi-diff wraps the built-in `write` and `edit` tools from the pi SDK, including single-edit and multi-edit `edit` calls. When the agent writes or edits a file:
@@ -215,6 +51,8 @@ pi-diff wraps the built-in `write` and `edit` tools from the pi SDK, including s
215
51
  3. **After the write** — computes a structured diff between old and new content
216
52
  4. **Renders** the diff with syntax highlighting and word-level emphasis
217
53
 
54
+ For `edit` calls, a `tool_call` handler runs **before** the tool executes and verifies that every `oldText` in the request is still present in the target file. If any `oldText` is missing, the call is blocked with a clear `VERIFY before EDIT` error, preventing the model from re-trying with stale text from a prior read.
55
+
218
56
  The rendering pipeline:
219
57
 
220
58
  ```
@@ -360,21 +198,35 @@ export DIFF_SPLIT_MIN_WIDTH=120
360
198
 
361
199
  ```
362
200
  src/
363
- └── index.ts # Extension entry point — wraps write/edit tools with diff rendering
201
+ ├── index.ts # Extension entry point — wraps write/edit tools with diff rendering
202
+ ├── edit-guard.ts # tool_call handler that blocks stale oldText in edit calls
203
+ ├── edit-guard.test.ts # Unit tests for the edit guard
204
+ ├── core/ # Pure data layer: diff parsing, conflict detection, line resolution
205
+ │ ├── config.ts
206
+ │ ├── conflicts.ts
207
+ │ ├── diff.ts # parseDiff, parsePatchFiles, resolveSepStyle
208
+ │ ├── replace.ts # safe in-place text replacement
209
+ │ ├── resolve-lines.ts
210
+ │ └── *.test.ts
211
+ └── review/ # Shared diff-rendering primitives used by the main extension
212
+ ├── git.ts # read git diffs from disk (execFileSync)
213
+ └── hunk-preview.ts # renderSplit, renderUnified, theme helpers (re-exported via __testing)
364
214
  ```
365
215
 
366
216
  ### Key internals
367
217
 
368
- | Component | Purpose |
369
- | -------------------- | -------------------------------------------------------------------------------- |
370
- | `parseDiff()` | Converts old/new content to structured `DiffLine[]` using `diff.structuredPatch` |
371
- | `hlBlock()` | Shiki ANSI highlighting with LRU cache (192 entries) |
372
- | `injectBg()` | Composites diff backgrounds into Shiki ANSI output (fg + bg layering) |
373
- | `wordDiffAnalysis()` | Single-pass word diff similarity score + character ranges |
374
- | `renderSplit()` | Side-by-side renderer with diagonal stripe fillers |
375
- | `renderUnified()` | Stacked single-column renderer |
376
- | `wrapAnsi()` | ANSI-aware line wrapping with state carry-forward |
377
- | `shouldUseSplit()` | Heuristic: split vs unified based on terminal width and wrap ratio |
218
+ All listed symbols are exposed under the `__testing` export for unit tests:
219
+
220
+ | Symbol | Source | Purpose |
221
+ | -------------------------- | ----------------------- | ----------------------------------------------------------------------- |
222
+ | `parseDiff()` | `core/diff.ts` | Convert old/new content to structured `DiffLine[]` (added/removed/chars) |
223
+ | `parsePatchFiles()` | `core/diff.ts` | Parse a full `git apply` patch into per-file diff objects |
224
+ | `resolveSepStyle()` | `core/diff.ts` | Pick the per-side separator style (` ` vs `╱`) for split view |
225
+ | `getSepStyle()` | `review/hunk-preview.ts`| Same as above for the shared rendering layer |
226
+ | `computeHunkBlocks()` | `review/hunk-preview.ts`| Build add/remove/modify line blocks with column ranges for split view |
227
+ | `renderSplit()` | `review/hunk-preview.ts`| Side-by-side renderer with diagonal stripe fillers |
228
+ | `renderUnified()` | `review/hunk-preview.ts`| Stacked single-column renderer |
229
+ | `normalizeShikiContrast()` | `review/hunk-preview.ts` | Lighten/darken Shiki output to match the surrounding TUI theme |
378
230
 
379
231
  ### Rendering constants
380
232
 
@@ -423,12 +275,13 @@ pi install .
423
275
  pi-diff is a **pi extension** — a TypeScript file that exports a default function receiving the pi API:
424
276
 
425
277
  ```typescript
426
- export default function piDiffExtension(pi: any): void {
427
- // Get SDK tools
278
+ import { registerEditGuard } from "./edit-guard.js";
279
+
280
+ export default function piDiffExtension(pi: ExtensionAPI): void {
281
+ // Wrap the built-in write/edit tools with diff rendering
428
282
  const origWrite = createWriteTool(cwd);
429
283
  const origEdit = createEditTool(cwd);
430
284
 
431
- // Register enhanced versions
432
285
  pi.registerTool({
433
286
  ...origWrite,
434
287
  name: "write",
@@ -436,15 +289,18 @@ export default function piDiffExtension(pi: any): void {
436
289
  renderCall: (...) => { /* preview */ },
437
290
  renderResult: (...) => { /* render diff */ },
438
291
  });
292
+
293
+ // Block stale-oldText edit calls before they run
294
+ registerEditGuard(pi);
439
295
  }
440
296
  ```
441
297
 
442
298
  Extensions can:
443
299
 
444
300
  - **Register tools** — `pi.registerTool(definition)`
445
- - **Listen to events** — `pi.on("session_start" | "input" | "before_tool_call" | ...)`
301
+ - **Listen to events** — `pi.on("session_start" | "input" | "tool_call" | "session_shutdown" | ...)`
302
+ - `tool_call` handlers can `block: true` to prevent the call from running
446
303
  - **Register commands** — `pi.registerCommand("/name", handler)`
447
- - **Register providers** — `pi.registerProvider("name", config)`
448
304
 
449
305
  See the [pi docs](https://pi.dev) for the full extension API.
450
306
 
@@ -0,0 +1,2 @@
1
+ export declare function registerEditGuard(pi: unknown): void;
2
+ //# sourceMappingURL=edit-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-guard.d.ts","sourceRoot":"","sources":["../src/edit-guard.ts"],"names":[],"mappings":"AAkCA,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,CAQnD"}
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Block edit tool calls when oldText is not present in the target file.
3
+ // Forces the model to re-read the file before editing instead of guessing
4
+ // stale oldText and getting caught in repeated retry loops.
5
+ import { readFileSync } from "node:fs";
6
+ const guardEditToolCall = async (event, _ctx) => {
7
+ const { path, edits } = event.input;
8
+ if (!edits || !Array.isArray(edits))
9
+ return;
10
+ let fileContent;
11
+ try {
12
+ fileContent = readFileSync(path, "utf8");
13
+ }
14
+ catch {
15
+ return;
16
+ }
17
+ for (const edit of edits) {
18
+ if (!fileContent.includes(edit.oldText)) {
19
+ return {
20
+ block: true,
21
+ reason: `oldText not found in ${path}. Re-read the file and copy the exact text (edit protocol: VERIFY before EDIT).`,
22
+ };
23
+ }
24
+ }
25
+ };
26
+ export function registerEditGuard(pi) {
27
+ if (pi == null)
28
+ return;
29
+ const on = pi.on;
30
+ if (typeof on !== "function")
31
+ return;
32
+ pi.on("tool_call", guardEditToolCall);
33
+ }
34
+ //# sourceMappingURL=edit-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-guard.js","sourceRoot":"","sources":["../src/edit-guard.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,wEAAwE;AACxE,0EAA0E;AAC1E,4DAA4D;AAE5D,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAOvC,MAAM,iBAAiB,GAA6D,KAAK,EACxF,KAAK,EACL,IAAI,EACH,EAAE;IACH,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC;IACpC,IAAI,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO;IAC5C,IAAI,WAAmB,CAAC;IACxB,IAAI,CAAC;QACJ,WAAW,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1C,CAAC;IAAC,MAAM,CAAC;QACR,OAAO;IACR,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YACzC,OAAO;gBACN,KAAK,EAAE,IAAI;gBACX,MAAM,EAAE,wBAAwB,IAAI,iFAAiF;aACrH,CAAC;QACH,CAAC;IACF,CAAC;AACF,CAAC,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,EAAW;IAC5C,IAAI,EAAE,IAAI,IAAI;QAAE,OAAO;IACvB,MAAM,EAAE,GAAI,EAAuB,CAAC,EAAE,CAAC;IACvC,IAAI,OAAO,EAAE,KAAK,UAAU;QAAE,OAAO;IACpC,EAAyE,CAAC,EAAE,CAC5E,WAAW,EACX,iBAAiB,CACjB,CAAC;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EACL,iBAAiB,EAEjB,WAAW,EACX,KAAK,UAAU,EACf,SAAS,EACT,eAAe,EACf,eAAe,EAGhB,MAAM,gBAAgB,CAAC;AAaxB,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AAgdxD,oFAAoF;AACpF,UAAU,UAAU;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAuKD,iBAAS,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIpD;AAoWD,iBAAe,aAAa,CAC1B,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,GAAG,SAAmB,EACtB,EAAE,GAAE,UAAgC,GACnC,OAAO,CAAC,MAAM,CAAC,CAkIjB;AAMD,iBAAe,WAAW,CACxB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,GAAG,SAAoB,EACvB,EAAE,GAAE,UAAgC,GACnC,OAAO,CAAC,MAAM,CAAC,CA2KjB;AAMD,eAAO,MAAM,SAAS;;;;;;;;;CASrB,CAAC;AAEF,wBAA8B,qBAAqB,CAAC,EAAE,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAoxBnF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,OAAO,KAAK,EAEX,YAAY,EAGZ,MAAM,iCAAiC,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG1C,OAAO,EACL,iBAAiB,EAEjB,WAAW,EACX,KAAK,UAAU,EACf,SAAS,EACT,eAAe,EACf,eAAe,EAGhB,MAAM,gBAAgB,CAAC;AAYxB,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;AAgdxD,oFAAoF;AACpF,UAAU,UAAU;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAuKD,iBAAS,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIpD;AAoWD,iBAAe,aAAa,CAC1B,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,GAAG,SAAmB,EACtB,EAAE,GAAE,UAAgC,GACnC,OAAO,CAAC,MAAM,CAAC,CAkIjB;AAMD,iBAAe,WAAW,CACxB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,GAAG,SAAoB,EACvB,EAAE,GAAE,UAAgC,GACnC,OAAO,CAAC,MAAM,CAAC,CA2KjB;AAMD,eAAO,MAAM,SAAS;;;;;;;;;CASrB,CAAC;AAEF,wBAA8B,qBAAqB,CAAC,EAAE,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAipBnF"}
package/dist/index.js CHANGED
@@ -27,8 +27,7 @@ import * as Diff from "diff";
27
27
  import { configIndicatorStyle } from "./core/config.js";
28
28
  import { computeHunkBlocks, getSepStyle, parseDiff, parsePatchFiles, resolveSepStyle, sepLabelSplit, sepLabelUnified, } from "./core/diff.js";
29
29
  import { replace } from "./core/replace.js";
30
- import { resolveLinesFromPatch } from "./core/resolve-lines.js";
31
- import { registerReviewDiffCommand } from "./review/command.js";
30
+ import { registerEditGuard } from "./edit-guard.js";
32
31
  import { applyDiffPalette as applySharedDiffPalette, lang as detectDiffLanguage, renderSplit as renderSharedSplit, resolveDiffColors as resolveSharedDiffColors, themeCacheKey as sharedThemeCacheKey, } from "./review/hunk-preview.js";
33
32
  const DIFF_PRESETS = {
34
33
  default: {
@@ -1300,152 +1299,6 @@ export default async function diffRendererExtension(pi) {
1300
1299
  };
1301
1300
  return text;
1302
1301
  }
1303
- registerReviewDiffCommand(pi, cwd);
1304
- // ── resolve_lines tool ────────────────────────────────────────────────
1305
- pi.registerTool({
1306
- name: "resolve_lines",
1307
- label: "Resolve Lines",
1308
- description: `Resolve line numbers for an LLM code-review comment by matching its existing_code snippet against the git diff hunks. Uses a three-tier algorithm: new-side hunk match, old-side hunk match, then full file-content scan. Returns \`startLine\` and \`endLine\` when found, or \`unresolved: true\` when the snippet cannot be located.
1309
-
1310
- Three-tier resolution:
1311
- 1. Match against diff hunks (new-file side) — context + added lines
1312
- 2. Match against diff hunks (old-file side) — context + deleted lines
1313
- 3. Scan full file content for a consecutive line match
1314
-
1315
- Use after receiving a review comment with \`existing_code\` but zero or drifted line numbers. The tool fetches the current git diff for the file automatically when \`patchText\` is omitted.
1316
-
1317
- Examples:
1318
- resolve_lines({ existingCode: "const x = 1;", filePath: "src/index.ts" })
1319
- resolve_lines({ existingCode: "function foo() {", filePath: "src/utils.ts", patchText: "@@ -1,3 +1,4 @@..." })`,
1320
- promptSnippet: "Resolve line numbers for an LLM review comment by matching existing_code against git diff hunks.",
1321
- parameters: {
1322
- type: "object",
1323
- properties: {
1324
- existingCode: {
1325
- type: "string",
1326
- description: "The code snippet from the LLM review comment that shows the problematic code. Required.",
1327
- },
1328
- filePath: {
1329
- type: "string",
1330
- description: "Path to the file this comment is about, relative to repo root. Required.",
1331
- },
1332
- patchText: {
1333
- type: "string",
1334
- description: "Optional raw unified diff text for the file. When omitted, pi-diff fetches the git diff automatically.",
1335
- },
1336
- fileContent: {
1337
- type: "string",
1338
- description: "Optional full new-file content for fallback line scan after hunk matching fails.",
1339
- },
1340
- },
1341
- required: ["existingCode", "filePath"],
1342
- additionalProperties: false,
1343
- },
1344
- async execute(_tid, params = {}) {
1345
- try {
1346
- const { existingCode, filePath, patchText, fileContent } = params ?? {};
1347
- if (!existingCode || !filePath) {
1348
- return {
1349
- content: [
1350
- { type: "text", text: "Error: existingCode and filePath are required" },
1351
- ],
1352
- };
1353
- }
1354
- // Fetch git diff if patchText not provided
1355
- let patch = patchText;
1356
- if (!patch) {
1357
- const { execFileSync } = await import("node:child_process");
1358
- try {
1359
- patch = execFileSync("git", ["diff", "--no-ext-diff", "HEAD", "--", filePath], {
1360
- cwd,
1361
- encoding: "utf8",
1362
- maxBuffer: 1024 * 1024,
1363
- stdio: ["ignore", "pipe", "pipe"],
1364
- }).trim();
1365
- // Also try unstaged diff
1366
- const unstaged = execFileSync("git", ["diff", "--no-ext-diff", "--", filePath], {
1367
- cwd,
1368
- encoding: "utf8",
1369
- maxBuffer: 1024 * 1024,
1370
- stdio: ["ignore", "pipe", "pipe"],
1371
- }).trim();
1372
- if (unstaged) {
1373
- patch = patch ? [patch, unstaged].filter(Boolean).join("\n") : unstaged;
1374
- }
1375
- }
1376
- catch {
1377
- return {
1378
- content: [
1379
- { type: "text", text: `Error: could not read git diff for ${filePath}` },
1380
- ],
1381
- };
1382
- }
1383
- }
1384
- if (!patch) {
1385
- return {
1386
- content: [
1387
- {
1388
- type: "text",
1389
- text: `No diff found for ${filePath} — file may not have changes`,
1390
- },
1391
- ],
1392
- details: { unresolved: true },
1393
- };
1394
- }
1395
- const result = resolveLinesFromPatch(existingCode, patch, fileContent);
1396
- if ("unresolved" in result) {
1397
- return {
1398
- content: [
1399
- {
1400
- type: "text",
1401
- text: `Could not resolve line numbers for existing_code in ${filePath}`,
1402
- },
1403
- ],
1404
- details: { unresolved: true },
1405
- };
1406
- }
1407
- return {
1408
- content: [
1409
- {
1410
- type: "text",
1411
- text: `Resolved ${filePath}:${result.startLine}-${result.endLine}`,
1412
- },
1413
- ],
1414
- details: { startLine: result.startLine, endLine: result.endLine },
1415
- };
1416
- }
1417
- catch (error) {
1418
- const message = error instanceof Error ? error.message : String(error);
1419
- return {
1420
- content: [{ type: "text", text: `Error: ${message}` }],
1421
- details: { unresolved: true, error: message },
1422
- };
1423
- }
1424
- },
1425
- renderCall(args, theme, ctx) {
1426
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1427
- const fp = typeof args?.filePath === "string" ? args.filePath : "";
1428
- text.setText(`${theme.fg("toolTitle", theme.bold("resolve_lines"))} ${theme.fg("accent", fp)}`);
1429
- return text;
1430
- },
1431
- renderResult(result, _opt, theme, ctx) {
1432
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1433
- if (ctx.isError || result.details?.error) {
1434
- text.setText(`\n${theme.fg("error", result.details?.error ?? "resolve_lines failed")}`);
1435
- return text;
1436
- }
1437
- if (result.details?.unresolved) {
1438
- text.setText(` ${theme.fg("muted", "unresolved")}`);
1439
- return text;
1440
- }
1441
- if (result.details?.startLine != null) {
1442
- text.setText(` ${theme.fg("success", `lines ${result.details.startLine}-${result.details.endLine}`)}`);
1443
- return text;
1444
- }
1445
- text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "").slice(0, 120))}`);
1446
- return text;
1447
- },
1448
- });
1449
1302
  // =======================================================================
1450
1303
  // write
1451
1304
  // =======================================================================
@@ -1676,7 +1529,7 @@ Examples:
1676
1529
  content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1677
1530
  details: {
1678
1531
  _type: "editInfo",
1679
- summary,
1532
+ summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1680
1533
  filePath: fp,
1681
1534
  editLine,
1682
1535
  diff: diffData,
@@ -1687,6 +1540,15 @@ Examples:
1687
1540
  },
1688
1541
  };
1689
1542
  }
1543
+ // Compute the line of the first edit for the title summary
1544
+ const firstEditLine = (() => {
1545
+ if (!operations[0]?.newText)
1546
+ return 0;
1547
+ const idx = content.indexOf(operations[0].newText);
1548
+ if (idx < 0)
1549
+ return 0;
1550
+ return content.slice(0, idx).split("\n").length;
1551
+ })();
1690
1552
  // Merge all diffs into one combined view for rendering
1691
1553
  const merged = {
1692
1554
  lines: diffs.flatMap((diff, i) => [
@@ -1710,7 +1572,7 @@ Examples:
1710
1572
  content: [{ type: "text", text: `Edited ${sp(fp)}` }],
1711
1573
  details: {
1712
1574
  _type: "multiEditInfo",
1713
- summary,
1575
+ summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1714
1576
  filePath: fp,
1715
1577
  editCount: operations.length,
1716
1578
  diffLineCount: merged.lines.length,
@@ -1749,7 +1611,7 @@ Examples:
1749
1611
  : diffs[0];
1750
1612
  result.details = {
1751
1613
  _type: "editInfo",
1752
- summary,
1614
+ summary: editLine > 0 ? `${summary} at line ${editLine}` : summary,
1753
1615
  filePath: fp,
1754
1616
  editLine,
1755
1617
  diff: diffData,
@@ -1779,9 +1641,21 @@ Examples:
1779
1641
  removed: diffs.reduce((sum, diff) => sum + diff.removed, 0),
1780
1642
  chars: diffs.reduce((sum, diff) => sum + diff.chars, 0),
1781
1643
  };
1644
+ let firstEditLine = 0;
1645
+ try {
1646
+ if (fp) {
1647
+ const f = readFileSync(fp, "utf8");
1648
+ const idx = f.indexOf(operations[0].newText);
1649
+ if (idx >= 0)
1650
+ firstEditLine = f.slice(0, idx).split("\n").length;
1651
+ }
1652
+ }
1653
+ catch {
1654
+ firstEditLine = 0;
1655
+ }
1782
1656
  result.details = {
1783
1657
  _type: "multiEditInfo",
1784
- summary,
1658
+ summary: firstEditLine > 0 ? `${summary} at line ${firstEditLine}` : summary,
1785
1659
  filePath: fp,
1786
1660
  editCount: operations.length,
1787
1661
  diffLineCount: merged.lines.length,
@@ -1797,7 +1671,24 @@ Examples:
1797
1671
  resolvePreviewDiffColors(theme);
1798
1672
  if (ctx.argsComplete && operations.length > 0) {
1799
1673
  const { totalAdded, totalRemoved } = summarizeEditOperations(operations);
1800
- text.setText(formatToolTitle("edit", fp, theme, termW(), ` ${theme.fg("muted", summarize(totalAdded, totalRemoved))}`));
1674
+ // Compute the line number for the call preview by reading the
1675
+ // file and finding the first oldText. This lets the title show
1676
+ // "at line N" even before the edit executes, matching what the
1677
+ // result title will show.
1678
+ let previewLine = 0;
1679
+ try {
1680
+ if (fp && existsSync(fp)) {
1681
+ const cur = readFileSync(fp, "utf-8");
1682
+ const idx = cur.indexOf(operations[0].oldText);
1683
+ if (idx >= 0)
1684
+ previewLine = cur.slice(0, idx).split("\n").length;
1685
+ }
1686
+ }
1687
+ catch {
1688
+ previewLine = 0;
1689
+ }
1690
+ const loc = previewLine > 0 ? ` ${theme.fg("muted", `at line ${previewLine}`)}` : "";
1691
+ text.setText(formatToolTitle("edit", fp, theme, termW(), ` ${theme.fg("muted", summarize(totalAdded, totalRemoved))}${loc}`));
1801
1692
  }
1802
1693
  else {
1803
1694
  text.setText(formatToolTitle("edit", fp, theme, termW()));
@@ -1817,19 +1708,16 @@ Examples:
1817
1708
  }
1818
1709
  const d = result.details;
1819
1710
  if (d?._type === "editInfo" && d.diff) {
1820
- const loc = d.editLine > 0 ? ` ${theme.fg("muted", `at line ${d.editLine}`)}` : "";
1821
- setDiffPreviewTask(text, "ed", `${d.summary}${loc}`, d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx);
1711
+ setDiffPreviewTask(text, "ed", "", d.diff, d.language, MAX_PREVIEW_LINES, theme, ctx);
1822
1712
  return text;
1823
1713
  }
1824
1714
  if (d?._type === "editInfo") {
1825
- const { summary: s, editLine } = d;
1826
- const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
1827
- setToolHeaderText(text, `${s}${loc}`, theme);
1715
+ setToolHeaderText(text, "", theme);
1828
1716
  return text;
1829
1717
  }
1830
1718
  if (d?._type === "multiEditInfo") {
1831
- const { summary: s, editCount, diffLineCount, diff, language } = d;
1832
- const meta = `${editCount} edits ${s}${diffLineCountLabel(diffLineCount, theme)}`;
1719
+ const { editCount, diffLineCount, diff, language } = d;
1720
+ const meta = `${editCount} edits${diffLineCountLabel(diffLineCount, theme)}`;
1833
1721
  if (diff) {
1834
1722
  setDiffPreviewTask(text, "me", meta, diff, language, MAX_PREVIEW_LINES, theme, ctx);
1835
1723
  return text;
@@ -1842,5 +1730,6 @@ Examples:
1842
1730
  return text;
1843
1731
  },
1844
1732
  });
1733
+ registerEditGuard(pi);
1845
1734
  }
1846
1735
  //# sourceMappingURL=index.js.map