pi-diff-review 0.1.19 → 0.1.21
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 +13 -2
- package/extensions/review.ts +5 -1
- package/package.json +1 -1
- package/src/diff/source.ts +6 -41
- package/src/explanation/controller.ts +34 -1
- package/src/explanation/explainer.ts +22 -2
- package/src/index.ts +170 -226
- package/src/review/cache.ts +171 -0
- package/src/review/component.ts +85 -14
- package/src/review/prompt.ts +25 -2
- package/src/review/search.ts +83 -20
- package/src/review/types.ts +8 -0
- package/src/review/workspace-comments.ts +493 -0
- package/src/shared/args.ts +44 -0
- package/src/view/parser.ts +54 -0
- package/src/view/source.ts +132 -0
package/README.md
CHANGED
|
@@ -49,6 +49,14 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
49
49
|
|
|
50
50
|
`/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.
|
|
51
51
|
|
|
52
|
+
Open one or more files or folders with `/view`:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
/view src/index.ts src/review
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`/view` expands folders into text files, renders them in the same review UI, and lets you annotate actual code lines instead of diff hunks.
|
|
59
|
+
|
|
52
60
|
### Staged vs. unstaged changes
|
|
53
61
|
|
|
54
62
|
- `/diff` shows unstaged working-tree changes only.
|
|
@@ -59,6 +67,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
59
67
|
## Features
|
|
60
68
|
|
|
61
69
|
- `/diff` reviews the current unstaged `git diff`
|
|
70
|
+
- `/view <files-or-folders>` reviews source files directly
|
|
62
71
|
- `/diff --cached` reviews staged changes
|
|
63
72
|
- `/diff main...HEAD` reviews changes on the current branch relative to `main`
|
|
64
73
|
- `/diff <git-diff-args>` passes arguments through to `git diff`
|
|
@@ -69,7 +78,7 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
69
78
|
- `t` toggles inline comments/explanations
|
|
70
79
|
- `v` toggles the diff between unified and side-by-side split rendering
|
|
71
80
|
- `?` toggles an AI-generated explanation for the current hunk
|
|
72
|
-
- `/` searches diff
|
|
81
|
+
- `/` searches visible diff text, highlights matches, and `n/N` moves between them while a search is active
|
|
73
82
|
- `J/K` to extend a highlighted selection into a comment range
|
|
74
83
|
- `esc` clears the active selection, or exits review when no selection is active
|
|
75
84
|
- `n/p` to jump hunks
|
|
@@ -77,7 +86,9 @@ Review a branch or commit range by passing any `git diff` arguments after `/diff
|
|
|
77
86
|
- `C` to add or edit an overall diff comment
|
|
78
87
|
- `x` to delete a comment for the current line or selected range
|
|
79
88
|
- `Enter` to submit comments back to pi
|
|
80
|
-
- Comments are cached per session and restored when reopening the same diff
|
|
89
|
+
- Comments are cached per session and restored when reopening the same diff or view
|
|
90
|
+
- File comments are also persisted in a repo-local workspace store and shown again in `/view` or on matching lines in `/diff`
|
|
91
|
+
- The UI indicates persisted comments that are hidden in the current files, elsewhere in the workspace, stale, or orphaned
|
|
81
92
|
- `q` to exit
|
|
82
93
|
|
|
83
94
|
## Contributing
|
package/extensions/review.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
registerDiffReviewCommand,
|
|
4
|
+
registerViewCommand,
|
|
5
|
+
} from "../src/index.ts";
|
|
3
6
|
|
|
4
7
|
export default function (pi: ExtensionAPI) {
|
|
5
8
|
registerDiffReviewCommand(pi);
|
|
9
|
+
registerViewCommand(pi);
|
|
6
10
|
}
|
package/package.json
CHANGED
package/src/diff/source.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import type { DiffSource } from "../review/types.ts";
|
|
3
|
+
import { tokenizeShellArgs } from "../shared/args.ts";
|
|
3
4
|
|
|
4
5
|
export function parseDiffSource(args: string): DiffSource {
|
|
5
6
|
const trimmed = args.trim();
|
|
@@ -20,48 +21,12 @@ export function parseDiffSource(args: string): DiffSource {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function tokenizeDiffArgs(input: string): string[] {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
for (const char of input) {
|
|
29
|
-
if (escaping) {
|
|
30
|
-
current += char;
|
|
31
|
-
escaping = false;
|
|
32
|
-
continue;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (char === "\\" && quote !== "'") {
|
|
36
|
-
escaping = true;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if ((char === '"' || char === "'") && !quote) {
|
|
41
|
-
quote = char;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (char === quote) {
|
|
46
|
-
quote = undefined;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (/\s/.test(char) && !quote) {
|
|
51
|
-
if (current) {
|
|
52
|
-
args.push(current);
|
|
53
|
-
current = "";
|
|
54
|
-
}
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
current += char;
|
|
24
|
+
try {
|
|
25
|
+
return tokenizeShellArgs(input);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
throw new Error(message.replace(/in arguments$/, "in git diff args"));
|
|
59
29
|
}
|
|
60
|
-
|
|
61
|
-
if (escaping) current += "\\";
|
|
62
|
-
if (quote) throw new Error(`Unterminated ${quote} quote in git diff args`);
|
|
63
|
-
if (current) args.push(current);
|
|
64
|
-
return args;
|
|
65
30
|
}
|
|
66
31
|
|
|
67
32
|
export const DIFF_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
@@ -12,7 +12,40 @@ export function getCurrentHunkScope(
|
|
|
12
12
|
selected: number,
|
|
13
13
|
): ExplanationScope | undefined {
|
|
14
14
|
const selectedLine = lines[selected];
|
|
15
|
-
if (!selectedLine?.filePath
|
|
15
|
+
if (!selectedLine?.filePath) return undefined;
|
|
16
|
+
if (!selectedLine.commentable && !selectedLine.hunkLabel) return undefined;
|
|
17
|
+
|
|
18
|
+
if (!selectedLine.hunkLabel) {
|
|
19
|
+
let start = selected;
|
|
20
|
+
while (
|
|
21
|
+
start > 0 &&
|
|
22
|
+
lines[start - 1]?.filePath === selectedLine.filePath &&
|
|
23
|
+
lines[start - 1]?.commentable
|
|
24
|
+
) {
|
|
25
|
+
start--;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let end = selected;
|
|
29
|
+
while (
|
|
30
|
+
end + 1 < lines.length &&
|
|
31
|
+
lines[end + 1]?.filePath === selectedLine.filePath &&
|
|
32
|
+
lines[end + 1]?.commentable
|
|
33
|
+
) {
|
|
34
|
+
end++;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const diffText = lines
|
|
38
|
+
.slice(start, end + 1)
|
|
39
|
+
.map((line) => line.text.replace(/^ /, ""))
|
|
40
|
+
.join("\n");
|
|
41
|
+
return {
|
|
42
|
+
key: `file:${selectedLine.filePath}:${start}:${end}`,
|
|
43
|
+
kind: "file",
|
|
44
|
+
title: selectedLine.filePath,
|
|
45
|
+
filePath: selectedLine.filePath,
|
|
46
|
+
diffText,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
16
49
|
|
|
17
50
|
let start = selected;
|
|
18
51
|
while (
|
|
@@ -4,7 +4,7 @@ import type { AssistantMessage, Context } from "@earendil-works/pi-ai";
|
|
|
4
4
|
|
|
5
5
|
export type ExplanationScope = {
|
|
6
6
|
key: string;
|
|
7
|
-
kind: "hunk";
|
|
7
|
+
kind: "hunk" | "file";
|
|
8
8
|
title: string;
|
|
9
9
|
filePath?: string;
|
|
10
10
|
diffText: string;
|
|
@@ -30,10 +30,30 @@ export function buildAskPrompt(
|
|
|
30
30
|
scope: ExplanationScope,
|
|
31
31
|
question: string,
|
|
32
32
|
): string {
|
|
33
|
+
if (scope.kind === "file") {
|
|
34
|
+
return `Given this code excerpt:\n\`\`\`${scope.filePath ? ` ${scope.filePath}` : ""}\n${scope.diffText}\n\`\`\`\n\n${question}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
return `Given this git diff hunk:\n\`\`\`diff\n${scope.diffText}\n\`\`\`\n\n${question}`;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export function buildExplanationPrompt(scope: ExplanationScope): string {
|
|
41
|
+
if (scope.kind === "file") {
|
|
42
|
+
return `Explain this code excerpt for a reviewer.
|
|
43
|
+
|
|
44
|
+
Focus on:
|
|
45
|
+
- what this code is doing
|
|
46
|
+
- why it matters in context
|
|
47
|
+
- behavioral, API, or test implications
|
|
48
|
+
- notable risks or edge cases
|
|
49
|
+
|
|
50
|
+
Keep it concise and practical.
|
|
51
|
+
|
|
52
|
+
\`\`\`${scope.filePath ? ` ${scope.filePath}` : ""}
|
|
53
|
+
${scope.diffText}
|
|
54
|
+
\`\`\``;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
return `Explain this git diff hunk for a code reviewer.
|
|
38
58
|
|
|
39
59
|
Focus on:
|
|
@@ -72,7 +92,7 @@ export class PiModelDiffExplainer implements DiffExplainer {
|
|
|
72
92
|
|
|
73
93
|
const context: Context = {
|
|
74
94
|
systemPrompt:
|
|
75
|
-
"You explain code
|
|
95
|
+
"You explain code clearly and concisely for review. Focus on intent, behavior, and risk. Avoid restating every line.",
|
|
76
96
|
messages: [
|
|
77
97
|
{
|
|
78
98
|
role: "user",
|
package/src/index.ts
CHANGED
|
@@ -6,197 +6,35 @@ import type {
|
|
|
6
6
|
import { getDiff, parseDiffSource } from "./diff/source.ts";
|
|
7
7
|
import { parseDiff } from "./diff/parser.ts";
|
|
8
8
|
import { PiModelDiffExplainer } from "./explanation/explainer.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getCachedAsk,
|
|
11
|
+
getCachedComments,
|
|
12
|
+
getCachedExplanations,
|
|
13
|
+
persistCachedAsk,
|
|
14
|
+
persistCachedComments,
|
|
15
|
+
persistCachedExplanations,
|
|
16
|
+
} from "./review/cache.ts";
|
|
10
17
|
import { ReviewComponent } from "./review/component.ts";
|
|
18
|
+
import { buildReviewPrompt, buildViewReviewPrompt } from "./review/prompt.ts";
|
|
11
19
|
import type {
|
|
12
|
-
DiffSource,
|
|
13
|
-
PersistedAsk,
|
|
14
20
|
ReviewComment,
|
|
21
|
+
ReviewLine,
|
|
15
22
|
ReviewResult,
|
|
16
23
|
} from "./review/types.ts";
|
|
24
|
+
import { WorkspaceCommentStore } from "./review/workspace-comments.ts";
|
|
25
|
+
import { parseViewFiles } from "./view/parser.ts";
|
|
26
|
+
import { parseViewSource, resolveViewFiles } from "./view/source.ts";
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
type DiffReviewCacheEntry = {
|
|
23
|
-
cacheKey: string;
|
|
24
|
-
comments: ReviewComment[];
|
|
25
|
-
updatedAt: number;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type DiffExplanationCacheEntry = {
|
|
29
|
-
cacheKey: string;
|
|
30
|
-
explanations: Record<string, string>;
|
|
31
|
-
updatedAt: number;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
type DiffAskCacheEntry = {
|
|
35
|
-
cacheKey: string;
|
|
36
|
-
ask?: PersistedAsk;
|
|
37
|
-
updatedAt: number;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
function getDiffCacheKey(
|
|
41
|
-
cwd: string,
|
|
42
|
-
source: DiffSource,
|
|
43
|
-
diffText: string,
|
|
44
|
-
): string {
|
|
45
|
-
const hash = createHash("sha256").update(diffText).digest("hex");
|
|
46
|
-
return `${cwd}\0${source.label}\0${hash}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function getCachedComments(
|
|
50
|
-
ctx: ExtensionCommandContext,
|
|
51
|
-
cacheKey: string,
|
|
52
|
-
): Map<string, ReviewComment> {
|
|
53
|
-
let latest: DiffReviewCacheEntry | undefined;
|
|
54
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
55
|
-
if (
|
|
56
|
-
entry.type !== "custom" ||
|
|
57
|
-
entry.customType !== DIFF_REVIEW_CACHE_ENTRY
|
|
58
|
-
) {
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const data = entry.data as Partial<DiffReviewCacheEntry> | undefined;
|
|
63
|
-
if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) {
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
68
|
-
latest = {
|
|
69
|
-
cacheKey: data.cacheKey,
|
|
70
|
-
comments: data.comments,
|
|
71
|
-
updatedAt: data.updatedAt ?? 0,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return new Map(
|
|
77
|
-
(latest?.comments ?? []).map((comment) => [comment.id, comment]),
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function persistCachedComments(
|
|
82
|
-
pi: ExtensionAPI,
|
|
83
|
-
cacheKey: string,
|
|
84
|
-
comments: Iterable<ReviewComment>,
|
|
85
|
-
): void {
|
|
86
|
-
pi.appendEntry(DIFF_REVIEW_CACHE_ENTRY, {
|
|
87
|
-
cacheKey,
|
|
88
|
-
comments: [...comments],
|
|
89
|
-
updatedAt: Date.now(),
|
|
90
|
-
} satisfies DiffReviewCacheEntry);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function getCachedExplanations(
|
|
94
|
-
ctx: ExtensionCommandContext,
|
|
95
|
-
cacheKey: string,
|
|
96
|
-
): Map<string, string> {
|
|
97
|
-
let latest: DiffExplanationCacheEntry | undefined;
|
|
98
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
99
|
-
if (
|
|
100
|
-
entry.type !== "custom" ||
|
|
101
|
-
entry.customType !== DIFF_REVIEW_EXPLANATION_CACHE_ENTRY
|
|
102
|
-
) {
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const data = entry.data as Partial<DiffExplanationCacheEntry> | undefined;
|
|
107
|
-
if (
|
|
108
|
-
data?.cacheKey !== cacheKey ||
|
|
109
|
-
!data.explanations ||
|
|
110
|
-
typeof data.explanations !== "object" ||
|
|
111
|
-
Array.isArray(data.explanations)
|
|
112
|
-
) {
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
117
|
-
latest = {
|
|
118
|
-
cacheKey: data.cacheKey,
|
|
119
|
-
explanations: Object.fromEntries(
|
|
120
|
-
Object.entries(data.explanations).filter(
|
|
121
|
-
(entry): entry is [string, string] =>
|
|
122
|
-
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
123
|
-
),
|
|
124
|
-
),
|
|
125
|
-
updatedAt: data.updatedAt ?? 0,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return new Map(Object.entries(latest?.explanations ?? {}));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function persistCachedExplanations(
|
|
134
|
-
pi: ExtensionAPI,
|
|
135
|
-
cacheKey: string,
|
|
136
|
-
explanations: Map<string, string>,
|
|
137
|
-
): void {
|
|
138
|
-
pi.appendEntry(DIFF_REVIEW_EXPLANATION_CACHE_ENTRY, {
|
|
139
|
-
cacheKey,
|
|
140
|
-
explanations: Object.fromEntries(explanations),
|
|
141
|
-
updatedAt: Date.now(),
|
|
142
|
-
} satisfies DiffExplanationCacheEntry);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function getCachedAsk(
|
|
146
|
-
ctx: ExtensionCommandContext,
|
|
147
|
-
cacheKey: string,
|
|
148
|
-
): PersistedAsk | undefined {
|
|
149
|
-
let latest: DiffAskCacheEntry | undefined;
|
|
150
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
151
|
-
if (
|
|
152
|
-
entry.type !== "custom" ||
|
|
153
|
-
entry.customType !== DIFF_REVIEW_ASK_CACHE_ENTRY
|
|
154
|
-
) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const data = entry.data as Partial<DiffAskCacheEntry> | undefined;
|
|
159
|
-
if (data?.cacheKey !== cacheKey) continue;
|
|
160
|
-
|
|
161
|
-
const ask = data.ask;
|
|
162
|
-
const validAsk =
|
|
163
|
-
ask &&
|
|
164
|
-
typeof ask === "object" &&
|
|
165
|
-
typeof ask.scopeKey === "string" &&
|
|
166
|
-
typeof ask.anchorLineId === "string" &&
|
|
167
|
-
typeof ask.text === "string"
|
|
168
|
-
? ask
|
|
169
|
-
: undefined;
|
|
170
|
-
|
|
171
|
-
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
172
|
-
latest = {
|
|
173
|
-
cacheKey: data.cacheKey,
|
|
174
|
-
ask: validAsk,
|
|
175
|
-
updatedAt: data.updatedAt ?? 0,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return latest?.ask;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function persistCachedAsk(
|
|
184
|
-
pi: ExtensionAPI,
|
|
185
|
-
cacheKey: string,
|
|
186
|
-
ask?: PersistedAsk,
|
|
187
|
-
): void {
|
|
188
|
-
pi.appendEntry(DIFF_REVIEW_ASK_CACHE_ENTRY, {
|
|
189
|
-
cacheKey,
|
|
190
|
-
ask,
|
|
191
|
-
updatedAt: Date.now(),
|
|
192
|
-
} satisfies DiffAskCacheEntry);
|
|
28
|
+
function getReviewCacheKey(cwd: string, label: string, text: string): string {
|
|
29
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
30
|
+
return `${cwd}\0${label}\0${hash}`;
|
|
193
31
|
}
|
|
194
32
|
|
|
195
33
|
export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
196
34
|
pi.registerCommand("diff", {
|
|
197
35
|
description: "Review a git diff in a custom TUI (/diff [git diff args])",
|
|
198
36
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
199
|
-
let source
|
|
37
|
+
let source;
|
|
200
38
|
let diffText: string;
|
|
201
39
|
try {
|
|
202
40
|
source = parseDiffSource(args);
|
|
@@ -213,62 +51,168 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
213
51
|
}
|
|
214
52
|
|
|
215
53
|
const reviewLines = parseDiff(diffText);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
54
|
+
await openReview(pi, ctx, {
|
|
55
|
+
title: source.label,
|
|
56
|
+
promptLabel: source.promptLabel,
|
|
57
|
+
cacheKey: getReviewCacheKey(ctx.cwd, source.label, diffText),
|
|
58
|
+
reviewLines,
|
|
59
|
+
buildPrompt: (comments) =>
|
|
60
|
+
buildReviewPrompt(comments, source.promptLabel),
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function registerViewCommand(pi: ExtensionAPI): void {
|
|
67
|
+
pi.registerCommand("view", {
|
|
68
|
+
description:
|
|
69
|
+
"Review one or more files or folders in a custom TUI (/view <paths...>)",
|
|
70
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
71
|
+
let source;
|
|
72
|
+
let files: string[];
|
|
73
|
+
try {
|
|
74
|
+
source = parseViewSource(args);
|
|
75
|
+
files = resolveViewFiles(ctx.cwd, source);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
ctx.ui.notify(`Unable to open view: ${message}`, "error");
|
|
79
|
+
return;
|
|
225
80
|
}
|
|
226
|
-
|
|
81
|
+
|
|
82
|
+
if (files.length === 0) {
|
|
227
83
|
ctx.ui.notify(
|
|
228
|
-
|
|
84
|
+
"No viewable text files matched the requested paths.",
|
|
229
85
|
"info",
|
|
230
86
|
);
|
|
87
|
+
return;
|
|
231
88
|
}
|
|
232
|
-
if (ask) {
|
|
233
|
-
ctx.ui.notify("Restored cached ask answer.", "info");
|
|
234
|
-
}
|
|
235
89
|
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
90
|
+
const workspaceStore = new WorkspaceCommentStore(ctx.cwd);
|
|
91
|
+
const reviewLines = parseViewFiles(workspaceStore.rootPath, files);
|
|
92
|
+
const contentKey = reviewLines.map((line) => line.text).join("\n");
|
|
93
|
+
|
|
94
|
+
await openReview(pi, ctx, {
|
|
95
|
+
title: source.label,
|
|
96
|
+
promptLabel: source.promptLabel,
|
|
97
|
+
cacheKey: getReviewCacheKey(ctx.cwd, source.label, contentKey),
|
|
98
|
+
reviewLines,
|
|
99
|
+
workspaceStore,
|
|
100
|
+
buildPrompt: (comments) =>
|
|
101
|
+
buildViewReviewPrompt(comments, source.promptLabel),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function openReview(
|
|
108
|
+
pi: ExtensionAPI,
|
|
109
|
+
ctx: ExtensionCommandContext,
|
|
110
|
+
options: {
|
|
111
|
+
title: string;
|
|
112
|
+
promptLabel: string;
|
|
113
|
+
cacheKey: string;
|
|
114
|
+
reviewLines: ReviewLine[];
|
|
115
|
+
buildPrompt: (comments: ReviewComment[]) => string;
|
|
116
|
+
workspaceStore?: WorkspaceCommentStore;
|
|
117
|
+
},
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const workspaceStore =
|
|
120
|
+
options.workspaceStore ?? new WorkspaceCommentStore(ctx.cwd);
|
|
121
|
+
const cachedComments = getCachedComments(ctx, options.cacheKey);
|
|
122
|
+
const comments = workspaceStore.getVisibleComments(options.reviewLines);
|
|
123
|
+
for (const [id, comment] of cachedComments) {
|
|
124
|
+
comments.set(id, comment);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const explanations = getCachedExplanations(ctx, options.cacheKey);
|
|
128
|
+
const ask = getCachedAsk(ctx, options.cacheKey);
|
|
129
|
+
const summary = () => workspaceStore.summarize(options.reviewLines);
|
|
130
|
+
|
|
131
|
+
if (cachedComments.size > 0) {
|
|
132
|
+
ctx.ui.notify(
|
|
133
|
+
`Restored ${cachedComments.size} cached review comment${cachedComments.size === 1 ? "" : "s"}.`,
|
|
134
|
+
"info",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (explanations.size > 0) {
|
|
138
|
+
ctx.ui.notify(
|
|
139
|
+
`Restored ${explanations.size} cached explanation${explanations.size === 1 ? "" : "s"}.`,
|
|
140
|
+
"info",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (ask) {
|
|
144
|
+
ctx.ui.notify("Restored cached ask answer.", "info");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const initialSummary = summary();
|
|
148
|
+
if (initialSummary.hiddenInCurrentFiles > 0 || initialSummary.elsewhere > 0) {
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
[
|
|
151
|
+
initialSummary.hiddenInCurrentFiles > 0
|
|
152
|
+
? `${initialSummary.hiddenInCurrentFiles} comments hidden in current files`
|
|
153
|
+
: undefined,
|
|
154
|
+
initialSummary.elsewhere > 0
|
|
155
|
+
? `${initialSummary.elsewhere} comments elsewhere in the workspace`
|
|
156
|
+
: undefined,
|
|
157
|
+
]
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.join(" • "),
|
|
160
|
+
"info",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (initialSummary.stale > 0 || initialSummary.orphaned > 0) {
|
|
164
|
+
ctx.ui.notify(
|
|
165
|
+
[
|
|
166
|
+
initialSummary.stale > 0
|
|
167
|
+
? `${initialSummary.stale} stale comment${initialSummary.stale === 1 ? "" : "s"}`
|
|
168
|
+
: undefined,
|
|
169
|
+
initialSummary.orphaned > 0
|
|
170
|
+
? `${initialSummary.orphaned} orphaned comment${initialSummary.orphaned === 1 ? "" : "s"}`
|
|
171
|
+
: undefined,
|
|
172
|
+
]
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.join(" • "),
|
|
175
|
+
"warning",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await ctx.ui.custom<ReviewResult>(
|
|
180
|
+
(tui, theme, _keybindings, done) => {
|
|
181
|
+
return new ReviewComponent(
|
|
182
|
+
tui,
|
|
183
|
+
theme,
|
|
184
|
+
options.title,
|
|
185
|
+
options.reviewLines,
|
|
186
|
+
comments,
|
|
187
|
+
done,
|
|
188
|
+
new PiModelDiffExplainer(ctx),
|
|
189
|
+
(updatedComments) => {
|
|
190
|
+
persistCachedComments(pi, options.cacheKey, updatedComments.values());
|
|
191
|
+
workspaceStore.syncFromComments(
|
|
192
|
+
options.reviewLines,
|
|
193
|
+
updatedComments.values(),
|
|
257
194
|
);
|
|
258
195
|
},
|
|
196
|
+
explanations,
|
|
197
|
+
(updatedExplanations) => {
|
|
198
|
+
persistCachedExplanations(pi, options.cacheKey, updatedExplanations);
|
|
199
|
+
},
|
|
200
|
+
ask,
|
|
201
|
+
(updatedAsk) => {
|
|
202
|
+
persistCachedAsk(pi, options.cacheKey, updatedAsk);
|
|
203
|
+
},
|
|
204
|
+
() => summary(),
|
|
259
205
|
);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
260
208
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
209
|
+
if (!result || result.action !== "submit") return;
|
|
210
|
+
if (result.comments.length === 0) {
|
|
211
|
+
ctx.ui.notify("No review comments to send.", "info");
|
|
212
|
+
persistCachedComments(pi, options.cacheKey, []);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
267
215
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
buildReviewPrompt(result.comments, source.promptLabel),
|
|
271
|
-
);
|
|
272
|
-
},
|
|
273
|
-
});
|
|
216
|
+
persistCachedComments(pi, options.cacheKey, []);
|
|
217
|
+
pi.sendUserMessage(options.buildPrompt(result.comments));
|
|
274
218
|
}
|