pi-diff-review 0.1.24 → 0.1.26

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/README.md CHANGED
@@ -1,10 +1,12 @@
1
- <img width="1400" height="225" alt="pi-diff-review" src="https://github.com/user-attachments/assets/049b2df9-ec38-4a9a-a824-8d682e77dae1" />
2
-
3
- # pi-diff-review
4
-
5
- Embedded code reviews and AI summaries directly within [pi](https://pi.dev/).
6
-
7
- <img width="1986" height="1556" alt="pi-diff-review-screenshot" src="https://github.com/user-attachments/assets/5ddd7226-28d4-4617-8c5c-35b4b6af68dc" />
1
+ <div align="center">
2
+ <div>
3
+ <img height="96" alt="pi-diff-review" src="https://github.com/user-attachments/assets/7b97e6a2-9dc3-49f8-b932-f8a90a59bfcf" />
4
+ </div>
5
+ <b><i>Embedded code reviews and explanations directly within <a href="https://pi.dev">pi</a>.</i></b>
6
+ <div>
7
+ <img width="728" alt="image" src="https://github.com/user-attachments/assets/6bb72e25-369e-446b-888d-1561005c427e" />
8
+ </div>
9
+ </div>
8
10
 
9
11
  ## Install
10
12
 
@@ -57,6 +59,18 @@ Review a single file by using a git pathspec after `--`. Pi path autocomplete wo
57
59
 
58
60
  `/diff <git-diff-args>` is passed through to `git diff`, so these examples are equivalent to running `git diff`, `git diff --cached`, and `git diff main...HEAD` locally before opening the review UI.
59
61
 
62
+ Experimental: track reviewed turns while keeping the full overall diff visible:
63
+
64
+ ```text
65
+ /diff --turn-based
66
+ /diff --cached --turn-based
67
+ /diff main...HEAD --turn-based -- @src/index.ts
68
+ ```
69
+
70
+ Press `M` in a turn-based review to toggle the current hunk as reviewed. Later
71
+ runs show the requested overall diff and render reviewed changed lines with a
72
+ muted blue overlay.
73
+
60
74
  Open one or more files or folders with `/view`:
61
75
 
62
76
  ```text
@@ -79,6 +93,7 @@ Open one or more files or folders with `/view`:
79
93
  - `/diff --cached` reviews staged changes
80
94
  - `/diff main...HEAD` reviews changes on the current branch relative to `main`
81
95
  - `/diff <git-diff-args>` passes arguments through to `git diff`
96
+ - `/diff --turn-based` enables experimental reviewed-turn overlays with `M`
82
97
  - `h` toggles the command help modal
83
98
  - `j/k` or arrow keys to move
84
99
  - `g/G` to jump to the top or bottom of the diff
@@ -88,6 +103,7 @@ Open one or more files or folders with `/view`:
88
103
  - `ctrl-u` / `ctrl-d` to move up/down by half a page
89
104
  - `s` toggles inline comments/explanations
90
105
  - `v` toggles the diff between unified and side-by-side split rendering
106
+ - `w` toggles line wrap for long diff lines
91
107
  - `?` toggles an AI-generated explanation for the current hunk
92
108
  - `/` searches visible diff text, highlights matches, and `n/N` moves between them while a search is active
93
109
  - `J/K` to extend a highlighted selection into a comment range
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,23 +12,77 @@ export function parseDiffSource(args: string): DiffSource {
12
12
  };
13
13
  }
14
14
 
15
- const gitArgs = tokenizeDiffArgs(trimmed);
15
+ const { gitArgs, turnBased } = parseDiffArgs(trimmed);
16
+ if (!turnBased) {
17
+ return {
18
+ label: `git diff ${trimmed}`,
19
+ promptLabel: `\`git diff ${trimmed}\``,
20
+ args: gitArgs,
21
+ };
22
+ }
23
+
24
+ const gitDiffLabel =
25
+ gitArgs.length > 0 ? `git diff ${gitArgs.join(" ")}` : "unstaged git diff";
26
+ const promptLabel =
27
+ gitArgs.length > 0
28
+ ? `\`git diff ${gitArgs.join(" ")}\``
29
+ : "the current unstaged git diff";
30
+
16
31
  return {
17
- label: `git diff ${trimmed}`,
18
- promptLabel: `\`git diff ${trimmed}\``,
32
+ label: `${gitDiffLabel} with turn-based review overlay`,
33
+ promptLabel,
19
34
  args: gitArgs,
35
+ turnBased: true,
20
36
  };
21
37
  }
22
38
 
23
- function tokenizeDiffArgs(input: string): string[] {
39
+ function parseDiffArgs(input: string): {
40
+ gitArgs: string[];
41
+ turnBased: boolean;
42
+ } {
24
43
  try {
25
- return normalizeDiffPathspecs(tokenizeShellArgs(input));
44
+ const tokens = tokenizeShellArgs(input);
45
+ const { args, turnBased } = extractDiffReviewFlags(tokens);
46
+ return {
47
+ gitArgs: normalizeDiffPathspecs(args),
48
+ turnBased,
49
+ };
26
50
  } catch (error) {
27
51
  const message = error instanceof Error ? error.message : String(error);
28
52
  throw new Error(message.replace(/in arguments$/, "in git diff args"));
29
53
  }
30
54
  }
31
55
 
56
+ function extractDiffReviewFlags(tokens: string[]): {
57
+ args: string[];
58
+ turnBased: boolean;
59
+ } {
60
+ const args: string[] = [];
61
+ let turnBased = false;
62
+ let passthrough = false;
63
+
64
+ for (let index = 0; index < tokens.length; index++) {
65
+ const token = tokens[index]!;
66
+ if (token === "--") {
67
+ passthrough = true;
68
+ args.push(token);
69
+ continue;
70
+ }
71
+
72
+ if (!passthrough && token === "--turn-based") {
73
+ if (turnBased) {
74
+ throw new Error("--turn-based can only be provided once");
75
+ }
76
+ turnBased = true;
77
+ continue;
78
+ }
79
+
80
+ args.push(token);
81
+ }
82
+
83
+ return { args, turnBased };
84
+ }
85
+
32
86
  function normalizeDiffPathspecs(args: string[]): string[] {
33
87
  const separatorIndex = args.indexOf("--");
34
88
  if (separatorIndex < 0) return args;
@@ -0,0 +1,93 @@
1
+ import type { ReviewLine, ReviewSnapshotLine } from "../review/types.ts";
2
+
3
+ export function applyReviewedOverlay(
4
+ targetLines: ReviewLine[],
5
+ reviewedLines: ReviewSnapshotLine[],
6
+ ): number {
7
+ const reviewedKeys = new Set(
8
+ reviewedLines.flatMap((line) => getChangedLineKeys(line)),
9
+ );
10
+ let marked = 0;
11
+
12
+ for (const line of targetLines) {
13
+ if (getChangedLineKeys(line).some((key) => reviewedKeys.has(key))) {
14
+ line.reviewedOverlay = true;
15
+ marked++;
16
+ }
17
+ }
18
+
19
+ return marked;
20
+ }
21
+
22
+ export function markChangedLinesReviewed(lines: ReviewLine[]): number {
23
+ let marked = 0;
24
+
25
+ for (const line of lines) {
26
+ if (getChangedLineKeys(line).length === 0) continue;
27
+ if (!line.reviewedOverlay) marked++;
28
+ line.reviewedOverlay = true;
29
+ }
30
+
31
+ return marked;
32
+ }
33
+
34
+ export function clearReviewedOverlay(lines: ReviewLine[]): number {
35
+ let cleared = 0;
36
+
37
+ for (const line of lines) {
38
+ if (!line.reviewedOverlay) continue;
39
+ line.reviewedOverlay = false;
40
+ cleared++;
41
+ }
42
+
43
+ return cleared;
44
+ }
45
+
46
+ export function areAllChangedLinesReviewed(lines: ReviewLine[]): boolean {
47
+ const changedLines = lines.filter(
48
+ (line) => getChangedLineKeys(line).length > 0,
49
+ );
50
+ return (
51
+ changedLines.length > 0 &&
52
+ changedLines.every((line) => line.reviewedOverlay)
53
+ );
54
+ }
55
+
56
+ export function getReviewedLines(lines: ReviewLine[]): ReviewLine[] {
57
+ return lines.filter(
58
+ (line) => line.reviewedOverlay && getChangedLineKeys(line).length > 0,
59
+ );
60
+ }
61
+
62
+ function getChangedLineKeys(line: ReviewLine | ReviewSnapshotLine): string[] {
63
+ if (!line.filePath) return [];
64
+ if (line.kind === "add" && line.newLineNumber != null) {
65
+ return [
66
+ [
67
+ line.filePath,
68
+ line.kind,
69
+ String(line.newLineNumber),
70
+ getComparableText(line),
71
+ ].join("\0"),
72
+ ];
73
+ }
74
+
75
+ if (line.kind === "remove" && line.oldLineNumber != null) {
76
+ return [
77
+ [
78
+ line.filePath,
79
+ line.kind,
80
+ String(line.oldLineNumber),
81
+ getComparableText(line),
82
+ ].join("\0"),
83
+ ];
84
+ }
85
+
86
+ return [];
87
+ }
88
+
89
+ function getComparableText(line: ReviewLine | ReviewSnapshotLine): string {
90
+ return line.kind === "add" || line.kind === "remove"
91
+ ? line.text.slice(1)
92
+ : line.text;
93
+ }
package/src/index.ts CHANGED
@@ -5,11 +5,14 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { getDiff, parseDiffSource } from "./diff/source.ts";
7
7
  import { parseDiff } from "./diff/parser.ts";
8
+ import { applyReviewedOverlay } from "./diff/turn-based-overlay.ts";
8
9
  import { PiModelDiffExplainer } from "./explanation/explainer.ts";
9
10
  import {
11
+ getLastReviewSnapshot,
10
12
  getCachedAsk,
11
13
  getCachedComments,
12
14
  getCachedExplanations,
15
+ persistLastReviewSnapshot,
13
16
  persistCachedAsk,
14
17
  persistCachedComments,
15
18
  persistCachedExplanations,
@@ -30,6 +33,10 @@ function getReviewCacheKey(cwd: string, label: string, text: string): string {
30
33
  return `${cwd}\0${label}\0${hash}`;
31
34
  }
32
35
 
36
+ function getReviewScopeKey(cwd: string, args: string[]): string {
37
+ return `${cwd}\0diff\0${JSON.stringify(args)}`;
38
+ }
39
+
33
40
  export function registerDiffReviewCommand(pi: ExtensionAPI): void {
34
41
  pi.registerCommand("diff", {
35
42
  description: "Review a git diff in a custom TUI (/diff [git diff args])",
@@ -51,11 +58,34 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
51
58
  }
52
59
 
53
60
  const reviewLines = parseDiff(diffText);
61
+ const turnBasedScopeKey = source.turnBased
62
+ ? getReviewScopeKey(ctx.cwd, source.args)
63
+ : undefined;
64
+ if (turnBasedScopeKey) {
65
+ const previousLines = getLastReviewSnapshot(ctx, turnBasedScopeKey);
66
+ if (previousLines) {
67
+ const marked = applyReviewedOverlay(reviewLines, previousLines);
68
+ ctx.ui.notify(
69
+ `Marked ${marked} reviewed line${marked === 1 ? "" : "s"}.`,
70
+ "info",
71
+ );
72
+ } else {
73
+ ctx.ui.notify(
74
+ "No reviewed baseline found. Press M to toggle the current hunk as reviewed.",
75
+ "info",
76
+ );
77
+ }
78
+ }
54
79
  await openReview(pi, ctx, {
55
80
  title: source.label,
56
81
  promptLabel: source.promptLabel,
57
82
  cacheKey: getReviewCacheKey(ctx.cwd, source.label, diffText),
58
83
  reviewLines,
84
+ markReviewed:
85
+ turnBasedScopeKey == null
86
+ ? undefined
87
+ : (reviewedLines) =>
88
+ persistLastReviewSnapshot(pi, turnBasedScopeKey, reviewedLines),
59
89
  buildPrompt: (comments) =>
60
90
  buildReviewPrompt(comments, source.promptLabel),
61
91
  });
@@ -113,6 +143,7 @@ async function openReview(
113
143
  cacheKey: string;
114
144
  reviewLines: ReviewLine[];
115
145
  buildPrompt: (comments: ReviewComment[]) => string;
146
+ markReviewed?: (reviewedLines: ReviewLine[]) => void;
116
147
  workspaceStore?: WorkspaceCommentStore;
117
148
  },
118
149
  ): Promise<void> {
@@ -202,6 +233,9 @@ async function openReview(
202
233
  persistCachedAsk(pi, options.cacheKey, updatedAsk);
203
234
  },
204
235
  () => summary(),
236
+ options.markReviewed
237
+ ? (reviewedLines) => options.markReviewed?.(reviewedLines)
238
+ : undefined,
205
239
  );
206
240
  },
207
241
  );
@@ -2,11 +2,17 @@ import type {
2
2
  ExtensionAPI,
3
3
  ExtensionCommandContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import type { PersistedAsk, ReviewComment } from "./types.ts";
5
+ import type {
6
+ PersistedAsk,
7
+ ReviewComment,
8
+ ReviewLine,
9
+ ReviewSnapshotLine,
10
+ } from "./types.ts";
6
11
 
7
12
  const REVIEW_COMMENT_CACHE_ENTRY = "pi-diff-review-cache";
8
13
  const REVIEW_EXPLANATION_CACHE_ENTRY = "pi-diff-review-explanation-cache";
9
14
  const REVIEW_ASK_CACHE_ENTRY = "pi-diff-review-ask-cache";
15
+ const REVIEW_SNAPSHOT_CACHE_ENTRY = "pi-diff-review-snapshot-cache";
10
16
 
11
17
  type ReviewCommentCacheEntry = {
12
18
  cacheKey: string;
@@ -26,6 +32,12 @@ type ReviewAskCacheEntry = {
26
32
  updatedAt: number;
27
33
  };
28
34
 
35
+ type ReviewSnapshotCacheEntry = {
36
+ scopeKey: string;
37
+ lines: ReviewSnapshotLine[];
38
+ updatedAt: number;
39
+ };
40
+
29
41
  export function getCachedComments(
30
42
  ctx: ExtensionCommandContext,
31
43
  cacheKey: string,
@@ -169,3 +181,62 @@ export function persistCachedAsk(
169
181
  updatedAt: Date.now(),
170
182
  } satisfies ReviewAskCacheEntry);
171
183
  }
184
+
185
+ export function getLastReviewSnapshot(
186
+ ctx: ExtensionCommandContext,
187
+ scopeKey: string,
188
+ ): ReviewSnapshotLine[] | undefined {
189
+ let latest: ReviewSnapshotCacheEntry | undefined;
190
+ for (const entry of ctx.sessionManager.getEntries()) {
191
+ if (
192
+ entry.type !== "custom" ||
193
+ entry.customType !== REVIEW_SNAPSHOT_CACHE_ENTRY
194
+ ) {
195
+ continue;
196
+ }
197
+
198
+ const data = entry.data as Partial<ReviewSnapshotCacheEntry> | undefined;
199
+ if (data?.scopeKey !== scopeKey || !Array.isArray(data.lines)) continue;
200
+
201
+ const lines = data.lines.filter(isReviewSnapshotLine);
202
+ if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
203
+ latest = {
204
+ scopeKey: data.scopeKey,
205
+ lines,
206
+ updatedAt: data.updatedAt ?? 0,
207
+ };
208
+ }
209
+ }
210
+
211
+ return latest?.lines;
212
+ }
213
+
214
+ export function persistLastReviewSnapshot(
215
+ pi: ExtensionAPI,
216
+ scopeKey: string,
217
+ lines: ReviewLine[] | ReviewSnapshotLine[],
218
+ ): void {
219
+ pi.appendEntry(REVIEW_SNAPSHOT_CACHE_ENTRY, {
220
+ scopeKey,
221
+ lines: lines.map((line) => ({
222
+ kind: line.kind,
223
+ text: line.text,
224
+ filePath: line.filePath,
225
+ oldLineNumber: line.oldLineNumber,
226
+ newLineNumber: line.newLineNumber,
227
+ })),
228
+ updatedAt: Date.now(),
229
+ } satisfies ReviewSnapshotCacheEntry);
230
+ }
231
+
232
+ function isReviewSnapshotLine(value: unknown): value is ReviewSnapshotLine {
233
+ if (!value || typeof value !== "object") return false;
234
+ const line = value as Partial<ReviewSnapshotLine>;
235
+ return (
236
+ typeof line.kind === "string" &&
237
+ typeof line.text === "string" &&
238
+ (line.filePath == null || typeof line.filePath === "string") &&
239
+ (line.oldLineNumber == null || typeof line.oldLineNumber === "number") &&
240
+ (line.newLineNumber == null || typeof line.newLineNumber === "number")
241
+ );
242
+ }
@@ -31,6 +31,12 @@ import { ReviewSearchState } from "./search.ts";
31
31
  import { buildReviewFileIndex, type ReviewFileSection } from "./files.ts";
32
32
  import { padToWidth, lineNumberCell } from "../render/utils.ts";
33
33
  import { buildSplitDiffRows } from "../diff/split.ts";
34
+ import {
35
+ areAllChangedLinesReviewed,
36
+ clearReviewedOverlay,
37
+ getReviewedLines,
38
+ markChangedLinesReviewed,
39
+ } from "../diff/turn-based-overlay.ts";
34
40
  import type {
35
41
  DiffRenderMode,
36
42
  PersistedAsk,
@@ -55,8 +61,8 @@ type InlineBoxRow = {
55
61
 
56
62
  type AnnotatedDiffRow =
57
63
  | { kind: "file-header"; file: ReviewFileSection }
58
- | { kind: "diff"; lineIndex: number }
59
- | { kind: "split"; splitRowIndex: number }
64
+ | { kind: "diff"; lineIndex: number; segmentIndex: number }
65
+ | { kind: "split"; splitRowIndex: number; segmentIndex: number }
60
66
  | InlineBoxRow;
61
67
 
62
68
  const HELP_COMMANDS = [
@@ -71,12 +77,14 @@ const HELP_COMMANDS = [
71
77
  ["/", "search diff lines"],
72
78
  ["n / N", "jump between search matches"],
73
79
  ["J / K", "extend highlighted selection"],
80
+ ["M", "toggle current hunk reviewed"],
74
81
  ["c", "add or edit a line or range comment"],
75
82
  ["C", "add or edit an overall diff comment"],
76
83
  ["x", "delete the current line or range comment"],
77
84
  ["t", "toggle the file sidebar"],
78
85
  ["s", "toggle inline comments and explanations"],
79
86
  ["v", "toggle unified or split rendering"],
87
+ ["w", "toggle line wrap for long diff lines"],
80
88
  ["?", "toggle AI explanation for current hunk"],
81
89
  ["a", "ask a question about the current hunk"],
82
90
  ["Enter", "submit comments, save edits, or jump to search result"],
@@ -114,6 +122,7 @@ export class ReviewComponent {
114
122
  private annotatedRowsEditMode = false;
115
123
  private annotatedRowsEditingCommentKey?: string;
116
124
  private annotatedRowsMode?: DiffRenderMode;
125
+ private annotatedRowsLineWrapEnabled = false;
117
126
  private annotatedRowsInlineAnnotationsVisible = true;
118
127
  private annotatedRowsVisibleExplanationCount = 0;
119
128
  private annotatedRowByLineIndex?: number[];
@@ -134,6 +143,7 @@ export class ReviewComponent {
134
143
  private getWorkspaceCommentSummary?: (
135
144
  comments: Map<string, ReviewComment>,
136
145
  ) => WorkspaceCommentSummary | undefined,
146
+ private onMarkReviewed?: (reviewedLines: ReviewLine[]) => void,
137
147
  ) {
138
148
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
139
149
  this.navigation = new ReviewNavigationState(
@@ -258,6 +268,10 @@ export class ReviewComponent {
258
268
  return this.navigation.diffRenderMode;
259
269
  }
260
270
 
271
+ private get lineWrapEnabled(): boolean {
272
+ return this.navigation.lineWrapEnabled;
273
+ }
274
+
261
275
  handleInput(data: string): void {
262
276
  if (this.helpVisible) {
263
277
  if (data === "h" || matchesKey(data, "escape")) {
@@ -330,6 +344,10 @@ export class ReviewComponent {
330
344
  this.toggleDiffRenderMode();
331
345
  return;
332
346
  }
347
+ if (data === "w") {
348
+ this.toggleLineWrap();
349
+ return;
350
+ }
333
351
  if (data === "?") {
334
352
  this.toggleExplanationPane();
335
353
  return;
@@ -390,6 +408,10 @@ export class ReviewComponent {
390
408
  this.extendSelection(-1);
391
409
  return;
392
410
  }
411
+ if (data === "M") {
412
+ this.markCurrentDiffReviewed();
413
+ return;
414
+ }
393
415
  if (data === "n") {
394
416
  if (this.search.query) {
395
417
  this.jumpSearch(1);
@@ -478,7 +500,12 @@ export class ReviewComponent {
478
500
  ): string {
479
501
  const visibleLineCount = this.getVisibleLineIndexes().length;
480
502
  const summaryCount = this.explanationController.explanations.size;
481
- const base = `${visibleLineCount}/${this.lines.length} lines • ${this.fileIndex.sections.length} files • ${this.comments.size} comments • ${summaryCount} summaries${this.formatWorkspaceSummary(workspaceSummary)}`;
503
+ const reviewedOverlayCount = this.lines.filter(
504
+ (line) => line.reviewedOverlay,
505
+ ).length;
506
+ const reviewedOverlayText =
507
+ reviewedOverlayCount > 0 ? ` • ${reviewedOverlayCount} reviewed` : "";
508
+ const base = `${visibleLineCount}/${this.lines.length} lines • ${this.fileIndex.sections.length} files • ${this.comments.size} comments • ${summaryCount} summaries${reviewedOverlayText}${this.formatWorkspaceSummary(workspaceSummary)}`;
482
509
 
483
510
  if (this.editMode) {
484
511
  return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
@@ -626,6 +653,7 @@ export class ReviewComponent {
626
653
  this.renderDiffLine(
627
654
  line,
628
655
  index,
656
+ annotated.segmentIndex,
629
657
  width,
630
658
  index === this.selected,
631
659
  this.getSelectionBounds(),
@@ -635,7 +663,13 @@ export class ReviewComponent {
635
663
  }
636
664
 
637
665
  if (annotated.kind === "split") {
638
- output.push(this.renderSplitDiffRowAt(annotated.splitRowIndex, width));
666
+ output.push(
667
+ this.renderSplitDiffRowAt(
668
+ annotated.splitRowIndex,
669
+ annotated.segmentIndex,
670
+ width,
671
+ ),
672
+ );
639
673
  continue;
640
674
  }
641
675
 
@@ -653,6 +687,7 @@ export class ReviewComponent {
653
687
  this.annotatedRowsEditMode === this.editMode &&
654
688
  this.annotatedRowsEditingCommentKey === this.editingCommentKey &&
655
689
  this.annotatedRowsMode === this.diffRenderMode &&
690
+ this.annotatedRowsLineWrapEnabled === this.lineWrapEnabled &&
656
691
  this.annotatedRowsInlineAnnotationsVisible ===
657
692
  this.inlineAnnotationsVisible &&
658
693
  this.annotatedRowsVisibleExplanationCount ===
@@ -682,6 +717,7 @@ export class ReviewComponent {
682
717
  this.annotatedRowsEditMode = this.editMode;
683
718
  this.annotatedRowsEditingCommentKey = this.editingCommentKey;
684
719
  this.annotatedRowsMode = this.diffRenderMode;
720
+ this.annotatedRowsLineWrapEnabled = this.lineWrapEnabled;
685
721
  this.annotatedRowsInlineAnnotationsVisible = this.inlineAnnotationsVisible;
686
722
  this.annotatedRowsVisibleExplanationCount =
687
723
  this.visibleExplanationKeys.size;
@@ -702,7 +738,18 @@ export class ReviewComponent {
702
738
  index++
703
739
  ) {
704
740
  rowByLineIndex[index] = rows.length;
705
- rows.push({ kind: "diff", lineIndex: index });
741
+ const segmentCount = this.getDiffRowSegmentCount(
742
+ this.lines[index]!,
743
+ index,
744
+ width,
745
+ );
746
+ for (
747
+ let segmentIndex = 0;
748
+ segmentIndex < segmentCount;
749
+ segmentIndex++
750
+ ) {
751
+ rows.push({ kind: "diff", lineIndex: index, segmentIndex });
752
+ }
706
753
 
707
754
  if (!this.inlineAnnotationsVisible) continue;
708
755
 
@@ -736,7 +783,14 @@ export class ReviewComponent {
736
783
  rowByLineIndex[lineIndex] = rows.length;
737
784
  }
738
785
 
739
- rows.push({ kind: "split", splitRowIndex });
786
+ const segmentCount = this.getSplitRowSegmentCount(splitRow, width);
787
+ for (
788
+ let segmentIndex = 0;
789
+ segmentIndex < segmentCount;
790
+ segmentIndex++
791
+ ) {
792
+ rows.push({ kind: "split", splitRowIndex, segmentIndex });
793
+ }
740
794
  if (!this.inlineAnnotationsVisible) continue;
741
795
 
742
796
  for (const lineIndex of lineIndexes) {
@@ -1183,7 +1237,11 @@ export class ReviewComponent {
1183
1237
  );
1184
1238
  }
1185
1239
 
1186
- private renderSplitDiffRowAt(splitRowIndex: number, width: number): string {
1240
+ private renderSplitDiffRowAt(
1241
+ splitRowIndex: number,
1242
+ segmentIndex: number,
1243
+ width: number,
1244
+ ): string {
1187
1245
  const splitRow = this.getSplitDiffRows()[splitRowIndex];
1188
1246
  if (!splitRow) return " ".repeat(width);
1189
1247
 
@@ -1191,6 +1249,7 @@ export class ReviewComponent {
1191
1249
  return this.renderDiffLine(
1192
1250
  splitRow.cell.line,
1193
1251
  splitRow.cell.index,
1252
+ segmentIndex,
1194
1253
  width,
1195
1254
  splitRow.cell.index === this.selected,
1196
1255
  this.getSelectionBounds(),
@@ -1201,10 +1260,15 @@ export class ReviewComponent {
1201
1260
  const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
1202
1261
  const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
1203
1262
  const left = splitRow.left
1204
- ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left")
1263
+ ? this.renderSplitDiffCell(splitRow.left, leftWidth, "left", segmentIndex)
1205
1264
  : " ".repeat(leftWidth);
1206
1265
  const right = splitRow.right
1207
- ? this.renderSplitDiffCell(splitRow.right, rightWidth, "right")
1266
+ ? this.renderSplitDiffCell(
1267
+ splitRow.right,
1268
+ rightWidth,
1269
+ "right",
1270
+ segmentIndex,
1271
+ )
1208
1272
  : " ".repeat(rightWidth);
1209
1273
  return truncateToWidth(
1210
1274
  `${padToWidth(left, leftWidth)}${this.theme.fg("borderMuted", " │ ")}${padToWidth(right, rightWidth)}`,
@@ -1212,6 +1276,175 @@ export class ReviewComponent {
1212
1276
  );
1213
1277
  }
1214
1278
 
1279
+ private getDiffRowSegmentCount(
1280
+ line: ReviewLine,
1281
+ index: number,
1282
+ width: number,
1283
+ ): number {
1284
+ return this.getDiffRowSegments(line, index, width).length;
1285
+ }
1286
+
1287
+ private getSplitRowSegmentCount(row: SplitDiffRow, width: number): number {
1288
+ if (row.kind === "full") {
1289
+ return this.getDiffRowSegmentCount(row.cell.line, row.cell.index, width);
1290
+ }
1291
+
1292
+ const separatorWidth = 3;
1293
+ const leftWidth = Math.max(10, Math.floor((width - separatorWidth) / 2));
1294
+ const rightWidth = Math.max(10, width - leftWidth - separatorWidth);
1295
+ return Math.max(
1296
+ row.left
1297
+ ? this.getSplitDiffCellSegments(row.left, leftWidth, "left").length
1298
+ : 1,
1299
+ row.right
1300
+ ? this.getSplitDiffCellSegments(row.right, rightWidth, "right").length
1301
+ : 1,
1302
+ );
1303
+ }
1304
+
1305
+ private getDiffRowSegments(
1306
+ line: ReviewLine,
1307
+ index: number,
1308
+ width: number,
1309
+ ): string[] {
1310
+ const lineMark = this.getLineMark(line, index);
1311
+ const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1312
+ const prefix = this.styleDiffPrefix(line, `${lineMark} ${numbers} `);
1313
+ const continuationPrefix = this.styleDiffPrefix(
1314
+ line,
1315
+ `${lineMark} ${lineNumberCell()} ${lineNumberCell()} `,
1316
+ );
1317
+ return this.wrapDiffContentSegments(
1318
+ this.getRenderedDiffContent(line, index),
1319
+ prefix,
1320
+ continuationPrefix,
1321
+ width,
1322
+ );
1323
+ }
1324
+
1325
+ private getSplitDiffCellSegments(
1326
+ cell: SplitDiffCell,
1327
+ width: number,
1328
+ side: "left" | "right",
1329
+ ): string[] {
1330
+ const { line, index } = cell;
1331
+ const lineMark = this.getLineMark(line, index);
1332
+ const lineNumber =
1333
+ side === "left" ? line.oldLineNumber : line.newLineNumber;
1334
+ const prefix = this.styleDiffPrefix(
1335
+ line,
1336
+ `${lineMark} ${lineNumberCell(lineNumber)} `,
1337
+ );
1338
+ const continuationPrefix = this.styleDiffPrefix(
1339
+ line,
1340
+ `${lineMark} ${lineNumberCell()} `,
1341
+ );
1342
+ return this.wrapDiffContentSegments(
1343
+ this.getRenderedDiffContent(line, index),
1344
+ prefix,
1345
+ continuationPrefix,
1346
+ width,
1347
+ );
1348
+ }
1349
+
1350
+ private getRenderedDiffContent(line: ReviewLine, index: number): string {
1351
+ switch (line.kind) {
1352
+ case "add":
1353
+ case "remove":
1354
+ case "context":
1355
+ return this.getHighlightedDisplayText(line, index);
1356
+ case "hunk":
1357
+ return this.theme.fg("accent", this.getDisplayText(line));
1358
+ default:
1359
+ return this.theme.fg("muted", this.getDisplayText(line));
1360
+ }
1361
+ }
1362
+
1363
+ private styleDiffPrefix(line: ReviewLine, text: string): string {
1364
+ switch (line.kind) {
1365
+ case "add":
1366
+ return this.theme.fg("toolDiffAdded", text);
1367
+ case "remove":
1368
+ return this.theme.fg("toolDiffRemoved", text);
1369
+ case "context":
1370
+ return this.theme.fg("toolDiffContext", text);
1371
+ case "hunk":
1372
+ return this.theme.fg("accent", text);
1373
+ default:
1374
+ return this.theme.fg("muted", text);
1375
+ }
1376
+ }
1377
+
1378
+ private getLineMark(line: ReviewLine, index: number): string {
1379
+ const hasComment = this.getCommentKeysForLine(index).length > 0;
1380
+ if (hasComment) return this.theme.fg("borderAccent", "│");
1381
+ return " ";
1382
+ }
1383
+
1384
+ private markCurrentDiffReviewed(): void {
1385
+ if (!this.onMarkReviewed) return;
1386
+ const range = this.getCurrentHunkRange();
1387
+ if (!range) return;
1388
+
1389
+ const hunkLines = this.lines.slice(range.start, range.end + 1);
1390
+ if (areAllChangedLinesReviewed(hunkLines)) {
1391
+ clearReviewedOverlay(hunkLines);
1392
+ } else {
1393
+ markChangedLinesReviewed(hunkLines);
1394
+ }
1395
+
1396
+ this.onMarkReviewed(getReviewedLines(this.lines));
1397
+ this.invalidateAnnotatedRows();
1398
+ this.tui.requestRender(true);
1399
+ }
1400
+
1401
+ private getCurrentHunkRange(): SelectionBounds | undefined {
1402
+ const selectedLine = this.lines[this.selected];
1403
+ if (!selectedLine?.filePath || !selectedLine.hunkLabel) return undefined;
1404
+
1405
+ let start = this.selected;
1406
+ while (
1407
+ start > 0 &&
1408
+ this.lines[start - 1]?.filePath === selectedLine.filePath &&
1409
+ this.lines[start - 1]?.hunkLabel === selectedLine.hunkLabel
1410
+ ) {
1411
+ start--;
1412
+ }
1413
+
1414
+ const end = this.getHunkEndIndex(this.selected);
1415
+ return end == null ? undefined : { start, end };
1416
+ }
1417
+
1418
+ private wrapDiffContentSegments(
1419
+ content: string,
1420
+ prefix: string,
1421
+ continuationPrefix: string,
1422
+ width: number,
1423
+ ): string[] {
1424
+ const firstLineWidth = Math.max(1, width - visibleWidth(prefix));
1425
+ const continuationWidth = Math.max(
1426
+ 1,
1427
+ width - visibleWidth(continuationPrefix),
1428
+ );
1429
+
1430
+ if (!this.lineWrapEnabled) {
1431
+ return [`${prefix}${truncateToWidth(content, firstLineWidth)}`];
1432
+ }
1433
+
1434
+ const firstSegments = wrapTextWithAnsi(content, firstLineWidth);
1435
+ if (firstSegments.length === 0) return [prefix];
1436
+ if (firstSegments.length === 1) return [`${prefix}${firstSegments[0]}`];
1437
+
1438
+ const [firstSegment, ...remaining] = firstSegments;
1439
+ const wrapped = [`${prefix}${firstSegment}`];
1440
+ for (const segment of remaining) {
1441
+ for (const continuation of wrapTextWithAnsi(segment, continuationWidth)) {
1442
+ wrapped.push(`${continuationPrefix}${continuation}`);
1443
+ }
1444
+ }
1445
+ return wrapped;
1446
+ }
1447
+
1215
1448
  private getContentHeight(): number {
1216
1449
  const terminalRows = this.tui.terminal?.rows ?? 24;
1217
1450
  const headerHeight = 3;
@@ -1326,6 +1559,12 @@ export class ReviewComponent {
1326
1559
  this.tui.requestRender(true);
1327
1560
  }
1328
1561
 
1562
+ private toggleLineWrap(): void {
1563
+ this.navigation.toggleLineWrap();
1564
+ this.invalidateAnnotatedRows();
1565
+ this.tui.requestRender(true);
1566
+ }
1567
+
1329
1568
  private toggleInlineAnnotations(): void {
1330
1569
  this.inlineAnnotationsVisible = !this.inlineAnnotationsVisible;
1331
1570
  this.invalidateAnnotatedRows();
@@ -1600,19 +1839,18 @@ export class ReviewComponent {
1600
1839
  cell: SplitDiffCell,
1601
1840
  width: number,
1602
1841
  side: "left" | "right",
1842
+ segmentIndex: number,
1603
1843
  ): string {
1604
1844
  const { line, index } = cell;
1605
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1606
- const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1607
- const lineNumber =
1608
- side === "left" ? line.oldLineNumber : line.newLineNumber;
1609
- const prefix = `${commentMark} ${lineNumberCell(lineNumber)} `;
1610
- let styled = this.renderDiffRowContent(line, prefix, index);
1611
-
1612
- styled = truncateToWidth(styled, width);
1845
+ const styled =
1846
+ this.getSplitDiffCellSegments(cell, width, side)[segmentIndex] ??
1847
+ " ".repeat(width);
1613
1848
  const selection = this.getSelectionBounds();
1614
1849
  const inSelection =
1615
1850
  selection != null && index >= selection.start && index <= selection.end;
1851
+ if (line.reviewedOverlay) {
1852
+ return this.applyReviewedBackground(styled, width);
1853
+ }
1616
1854
  if (index === this.selected || inSelection) {
1617
1855
  return this.theme.bg("selectedBg", padToWidth(styled, width));
1618
1856
  }
@@ -1651,15 +1889,24 @@ export class ReviewComponent {
1651
1889
  styled: string,
1652
1890
  width: number,
1653
1891
  ): string {
1892
+ if (line.reviewedOverlay) {
1893
+ return this.applyReviewedBackground(styled, width);
1894
+ }
1895
+
1896
+ const padded = padToWidth(styled, width);
1654
1897
  if (line.kind === "add") {
1655
- return this.theme.bg("toolSuccessBg", padToWidth(styled, width));
1898
+ return this.theme.bg("toolSuccessBg", padded);
1656
1899
  }
1657
1900
  if (line.kind === "remove") {
1658
- return this.theme.bg("toolErrorBg", padToWidth(styled, width));
1901
+ return this.theme.bg("toolErrorBg", padded);
1659
1902
  }
1660
1903
  return styled;
1661
1904
  }
1662
1905
 
1906
+ private applyReviewedBackground(styled: string, width: number): string {
1907
+ return `\x1b[48;2;38;68;92m${padToWidth(styled, width)}\x1b[49m`;
1908
+ }
1909
+
1663
1910
  private renderDiffRowContent(
1664
1911
  line: ReviewLine,
1665
1912
  prefix: string,
@@ -1734,19 +1981,19 @@ export class ReviewComponent {
1734
1981
  private renderDiffLine(
1735
1982
  line: ReviewLine,
1736
1983
  index: number,
1984
+ segmentIndex: number,
1737
1985
  width: number,
1738
1986
  selected: boolean,
1739
1987
  selection?: SelectionBounds,
1740
1988
  ): string {
1741
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1742
- const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1743
- const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1744
- const prefix = `${commentMark} ${numbers} `;
1745
- let styled = this.renderDiffRowContent(line, prefix, index);
1746
-
1747
- styled = truncateToWidth(styled, width);
1989
+ const styled =
1990
+ this.getDiffRowSegments(line, index, width)[segmentIndex] ??
1991
+ " ".repeat(width);
1748
1992
  const inSelection =
1749
1993
  selection != null && index >= selection.start && index <= selection.end;
1994
+ if (line.reviewedOverlay) {
1995
+ return this.applyReviewedBackground(styled, width);
1996
+ }
1750
1997
  if (selected || inSelection) {
1751
1998
  return this.theme.bg("selectedBg", padToWidth(styled, width));
1752
1999
  }
@@ -10,6 +10,7 @@ export class ReviewNavigationState {
10
10
  scrollTop = 0;
11
11
  selectionAnchor?: number;
12
12
  diffRenderMode: DiffRenderMode = "unified";
13
+ lineWrapEnabled = false;
13
14
 
14
15
  constructor(
15
16
  private readonly lineCount: number,
@@ -84,6 +85,12 @@ export class ReviewNavigationState {
84
85
  return this.diffRenderMode;
85
86
  }
86
87
 
88
+ toggleLineWrap(): boolean {
89
+ this.lineWrapEnabled = !this.lineWrapEnabled;
90
+ this.scrollTop = 0;
91
+ return this.lineWrapEnabled;
92
+ }
93
+
87
94
  ensureScroll(
88
95
  viewportHeight: number,
89
96
  selectedDisplayRow: number,
@@ -25,6 +25,7 @@ export type ReviewLine = {
25
25
  newLineNumber?: number;
26
26
  commentable: boolean;
27
27
  hunkLabel?: string;
28
+ reviewedOverlay?: boolean;
28
29
  };
29
30
 
30
31
  export type ReviewResult =
@@ -40,8 +41,14 @@ export type DiffSource = {
40
41
  label: string;
41
42
  promptLabel: string;
42
43
  args: string[];
44
+ turnBased?: boolean;
43
45
  };
44
46
 
47
+ export type ReviewSnapshotLine = Pick<
48
+ ReviewLine,
49
+ "kind" | "text" | "filePath" | "oldLineNumber" | "newLineNumber"
50
+ >;
51
+
45
52
  export type PersistedAsk = {
46
53
  scopeKey: string;
47
54
  anchorLineId: string;