pi-diff-review 0.1.25 → 0.1.27

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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-diff-review",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Local diff review TUI extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,13 +34,13 @@
34
34
  "typecheck": "tsc --noEmit"
35
35
  },
36
36
  "devDependencies": {
37
- "@earendil-works/pi-ai": "^0.74.0",
38
- "@earendil-works/pi-coding-agent": "^0.74.1",
39
- "@earendil-works/pi-tui": "^0.74.1",
40
- "@types/node": "^24.12.4",
41
- "prettier": "^3.5.3",
42
- "tsx": "^4.22.1",
43
- "typescript": "^6.0.3"
37
+ "@earendil-works/pi-ai": "^0.85.1",
38
+ "@earendil-works/pi-coding-agent": "^0.85.1",
39
+ "@earendil-works/pi-tui": "^0.85.1",
40
+ "@types/node": "^26.5.1",
41
+ "prettier": "^3.9.6",
42
+ "tsx": "^4.23.13",
43
+ "typescript": "^7.0.2"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-ai": "*",
@@ -65,6 +65,6 @@
65
65
  "image": "https://github.com/user-attachments/assets/3fd00163-5d19-489b-94ed-3d4816c6cad3"
66
66
  },
67
67
  "dependencies": {
68
- "@pierre/diffs": "^1.1.21"
68
+ "@pierre/diffs": "^1.4.2"
69
69
  }
70
70
  }
@@ -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
+ }
@@ -1,4 +1,3 @@
1
- import { streamSimple } from "@earendil-works/pi-ai";
2
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
2
  import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
4
3
 
@@ -90,6 +89,11 @@ export class PiModelDiffExplainer implements DiffExplainer {
90
89
  throw new Error(auth.error);
91
90
  }
92
91
 
92
+ const provider = this.ctx.modelRegistry.getProvider(model.provider);
93
+ if (!provider) {
94
+ throw new Error(`No provider is registered for ${model.provider}.`);
95
+ }
96
+
93
97
  const context: Context = {
94
98
  systemPrompt:
95
99
  "You explain code clearly and concisely for review. Focus on intent, behavior, and risk. Avoid restating every line.",
@@ -105,7 +109,7 @@ export class PiModelDiffExplainer implements DiffExplainer {
105
109
  };
106
110
 
107
111
  let streamedText = "";
108
- const stream = streamSimple(model, context, {
112
+ const stream = provider.streamSimple(model, context, {
109
113
  apiKey: auth.apiKey,
110
114
  headers: auth.headers,
111
115
  signal: options.signal,
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,
@@ -71,6 +77,7 @@ 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"],
@@ -136,6 +143,7 @@ export class ReviewComponent {
136
143
  private getWorkspaceCommentSummary?: (
137
144
  comments: Map<string, ReviewComment>,
138
145
  ) => WorkspaceCommentSummary | undefined,
146
+ private onMarkReviewed?: (reviewedLines: ReviewLine[]) => void,
139
147
  ) {
140
148
  const firstCommentable = this.lines.findIndex((line) => line.commentable);
141
149
  this.navigation = new ReviewNavigationState(
@@ -400,6 +408,10 @@ export class ReviewComponent {
400
408
  this.extendSelection(-1);
401
409
  return;
402
410
  }
411
+ if (data === "M") {
412
+ this.markCurrentDiffReviewed();
413
+ return;
414
+ }
403
415
  if (data === "n") {
404
416
  if (this.search.query) {
405
417
  this.jumpSearch(1);
@@ -488,7 +500,12 @@ export class ReviewComponent {
488
500
  ): string {
489
501
  const visibleLineCount = this.getVisibleLineIndexes().length;
490
502
  const summaryCount = this.explanationController.explanations.size;
491
- 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)}`;
492
509
 
493
510
  if (this.editMode) {
494
511
  return `${base} • editing ${this.editingCommentKey === GLOBAL_COMMENT_KEY ? "overall comment" : "inline comment"}`;
@@ -1290,13 +1307,12 @@ export class ReviewComponent {
1290
1307
  index: number,
1291
1308
  width: number,
1292
1309
  ): string[] {
1293
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1294
- const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1310
+ const lineMark = this.getLineMark(line, index);
1295
1311
  const numbers = `${lineNumberCell(line.oldLineNumber)} ${lineNumberCell(line.newLineNumber)}`;
1296
- const prefix = this.styleDiffPrefix(line, `${commentMark} ${numbers} `);
1312
+ const prefix = this.styleDiffPrefix(line, `${lineMark} ${numbers} `);
1297
1313
  const continuationPrefix = this.styleDiffPrefix(
1298
1314
  line,
1299
- `${commentMark} ${lineNumberCell()} ${lineNumberCell()} `,
1315
+ `${lineMark} ${lineNumberCell()} ${lineNumberCell()} `,
1300
1316
  );
1301
1317
  return this.wrapDiffContentSegments(
1302
1318
  this.getRenderedDiffContent(line, index),
@@ -1312,17 +1328,16 @@ export class ReviewComponent {
1312
1328
  side: "left" | "right",
1313
1329
  ): string[] {
1314
1330
  const { line, index } = cell;
1315
- const hasComment = this.getCommentKeysForLine(index).length > 0;
1316
- const commentMark = hasComment ? this.theme.fg("borderAccent", "│") : " ";
1331
+ const lineMark = this.getLineMark(line, index);
1317
1332
  const lineNumber =
1318
1333
  side === "left" ? line.oldLineNumber : line.newLineNumber;
1319
1334
  const prefix = this.styleDiffPrefix(
1320
1335
  line,
1321
- `${commentMark} ${lineNumberCell(lineNumber)} `,
1336
+ `${lineMark} ${lineNumberCell(lineNumber)} `,
1322
1337
  );
1323
1338
  const continuationPrefix = this.styleDiffPrefix(
1324
1339
  line,
1325
- `${commentMark} ${lineNumberCell()} `,
1340
+ `${lineMark} ${lineNumberCell()} `,
1326
1341
  );
1327
1342
  return this.wrapDiffContentSegments(
1328
1343
  this.getRenderedDiffContent(line, index),
@@ -1360,6 +1375,46 @@ export class ReviewComponent {
1360
1375
  }
1361
1376
  }
1362
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
+
1363
1418
  private wrapDiffContentSegments(
1364
1419
  content: string,
1365
1420
  prefix: string,
@@ -1793,6 +1848,9 @@ export class ReviewComponent {
1793
1848
  const selection = this.getSelectionBounds();
1794
1849
  const inSelection =
1795
1850
  selection != null && index >= selection.start && index <= selection.end;
1851
+ if (line.reviewedOverlay) {
1852
+ return this.applyReviewedBackground(styled, width);
1853
+ }
1796
1854
  if (index === this.selected || inSelection) {
1797
1855
  return this.theme.bg("selectedBg", padToWidth(styled, width));
1798
1856
  }
@@ -1831,15 +1889,24 @@ export class ReviewComponent {
1831
1889
  styled: string,
1832
1890
  width: number,
1833
1891
  ): string {
1892
+ if (line.reviewedOverlay) {
1893
+ return this.applyReviewedBackground(styled, width);
1894
+ }
1895
+
1896
+ const padded = padToWidth(styled, width);
1834
1897
  if (line.kind === "add") {
1835
- return this.theme.bg("toolSuccessBg", padToWidth(styled, width));
1898
+ return this.theme.bg("toolSuccessBg", padded);
1836
1899
  }
1837
1900
  if (line.kind === "remove") {
1838
- return this.theme.bg("toolErrorBg", padToWidth(styled, width));
1901
+ return this.theme.bg("toolErrorBg", padded);
1839
1902
  }
1840
1903
  return styled;
1841
1904
  }
1842
1905
 
1906
+ private applyReviewedBackground(styled: string, width: number): string {
1907
+ return `\x1b[48;2;38;68;92m${padToWidth(styled, width)}\x1b[49m`;
1908
+ }
1909
+
1843
1910
  private renderDiffRowContent(
1844
1911
  line: ReviewLine,
1845
1912
  prefix: string,
@@ -1924,6 +1991,9 @@ export class ReviewComponent {
1924
1991
  " ".repeat(width);
1925
1992
  const inSelection =
1926
1993
  selection != null && index >= selection.start && index <= selection.end;
1994
+ if (line.reviewedOverlay) {
1995
+ return this.applyReviewedBackground(styled, width);
1996
+ }
1927
1997
  if (selected || inSelection) {
1928
1998
  return this.theme.bg("selectedBg", padToWidth(styled, width));
1929
1999
  }
@@ -25,11 +25,11 @@ 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 =
31
- | { action: "submit"; comments: ReviewComment[] }
32
- | { action: "cancel" };
32
+ { action: "submit"; comments: ReviewComment[] } | { action: "cancel" };
33
33
 
34
34
  export type SelectionBounds = {
35
35
  start: number;
@@ -40,8 +40,14 @@ export type DiffSource = {
40
40
  label: string;
41
41
  promptLabel: string;
42
42
  args: string[];
43
+ turnBased?: boolean;
43
44
  };
44
45
 
46
+ export type ReviewSnapshotLine = Pick<
47
+ ReviewLine,
48
+ "kind" | "text" | "filePath" | "oldLineNumber" | "newLineNumber"
49
+ >;
50
+
45
51
  export type PersistedAsk = {
46
52
  scopeKey: string;
47
53
  anchorLineId: string;