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 +22 -7
- package/package.json +9 -9
- package/src/diff/source.ts +59 -5
- package/src/diff/turn-based-overlay.ts +93 -0
- package/src/explanation/explainer.ts +6 -2
- package/src/index.ts +34 -0
- package/src/review/cache.ts +72 -1
- package/src/review/component.ts +81 -11
- package/src/review/types.ts +8 -2
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
<
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Embedded code reviews and
|
|
6
|
-
|
|
7
|
-
<img width="
|
|
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.
|
|
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.
|
|
38
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
39
|
-
"@earendil-works/pi-tui": "^0.
|
|
40
|
-
"@types/node": "^
|
|
41
|
-
"prettier": "^3.
|
|
42
|
-
"tsx": "^4.
|
|
43
|
-
"typescript": "^
|
|
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.
|
|
68
|
+
"@pierre/diffs": "^1.4.2"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/diff/source.ts
CHANGED
|
@@ -12,23 +12,77 @@ export function parseDiffSource(args: string): DiffSource {
|
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const gitArgs =
|
|
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:
|
|
18
|
-
promptLabel
|
|
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
|
|
39
|
+
function parseDiffArgs(input: string): {
|
|
40
|
+
gitArgs: string[];
|
|
41
|
+
turnBased: boolean;
|
|
42
|
+
} {
|
|
24
43
|
try {
|
|
25
|
-
|
|
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
|
);
|
package/src/review/cache.ts
CHANGED
|
@@ -2,11 +2,17 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type {
|
|
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
|
+
}
|
package/src/review/component.ts
CHANGED
|
@@ -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
|
|
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
|
|
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, `${
|
|
1312
|
+
const prefix = this.styleDiffPrefix(line, `${lineMark} ${numbers} `);
|
|
1297
1313
|
const continuationPrefix = this.styleDiffPrefix(
|
|
1298
1314
|
line,
|
|
1299
|
-
`${
|
|
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
|
|
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
|
-
`${
|
|
1336
|
+
`${lineMark} ${lineNumberCell(lineNumber)} `,
|
|
1322
1337
|
);
|
|
1323
1338
|
const continuationPrefix = this.styleDiffPrefix(
|
|
1324
1339
|
line,
|
|
1325
|
-
`${
|
|
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",
|
|
1898
|
+
return this.theme.bg("toolSuccessBg", padded);
|
|
1836
1899
|
}
|
|
1837
1900
|
if (line.kind === "remove") {
|
|
1838
|
-
return this.theme.bg("toolErrorBg",
|
|
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
|
}
|
package/src/review/types.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|