pi-diff-review 0.1.18 → 0.1.20
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 +12 -1
- package/extensions/review.ts +5 -1
- package/package.json +1 -1
- package/src/diff/source.ts +6 -41
- package/src/explanation/controller.ts +46 -1
- package/src/explanation/explainer.ts +22 -2
- package/src/index.ts +170 -160
- package/src/review/cache.ts +171 -0
- package/src/review/component.ts +134 -16
- package/src/review/prompt.ts +25 -2
- package/src/review/types.ts +14 -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`
|
|
@@ -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 (
|
|
@@ -62,12 +95,19 @@ export class ExplanationController {
|
|
|
62
95
|
private readonly onExplanationsChanged?: (
|
|
63
96
|
explanations: Map<string, string>,
|
|
64
97
|
) => void,
|
|
98
|
+
cachedAskText?: string,
|
|
99
|
+
private readonly onAskChanged?: (state?: ExplanationState) => void,
|
|
65
100
|
) {
|
|
66
101
|
for (const [key, text] of cachedExplanations) {
|
|
67
102
|
const trimmed = text.trim();
|
|
68
103
|
if (trimmed)
|
|
69
104
|
this.explanations.set(key, { status: "ready", text: trimmed });
|
|
70
105
|
}
|
|
106
|
+
|
|
107
|
+
const trimmedAskText = cachedAskText?.trim();
|
|
108
|
+
if (trimmedAskText) {
|
|
109
|
+
this.askState = { status: "ready", text: trimmedAskText };
|
|
110
|
+
}
|
|
71
111
|
}
|
|
72
112
|
|
|
73
113
|
get isAvailable(): boolean {
|
|
@@ -90,6 +130,7 @@ export class ExplanationController {
|
|
|
90
130
|
const requestId = ++this.askRequestId;
|
|
91
131
|
let text = "";
|
|
92
132
|
this.askState = { status: "loading", text };
|
|
133
|
+
this.onAskChanged?.(this.askState);
|
|
93
134
|
this.startLoadingTimer();
|
|
94
135
|
|
|
95
136
|
void this.explainer
|
|
@@ -99,6 +140,7 @@ export class ExplanationController {
|
|
|
99
140
|
if (requestId !== this.askRequestId) return;
|
|
100
141
|
text += delta;
|
|
101
142
|
this.askState = { status: "loading", text };
|
|
143
|
+
this.onAskChanged?.(this.askState);
|
|
102
144
|
this.tui.requestRender();
|
|
103
145
|
},
|
|
104
146
|
})
|
|
@@ -108,6 +150,7 @@ export class ExplanationController {
|
|
|
108
150
|
status: "ready",
|
|
109
151
|
text: finalText.trim() || text.trim() || "No answer returned.",
|
|
110
152
|
};
|
|
153
|
+
this.onAskChanged?.(this.askState);
|
|
111
154
|
})
|
|
112
155
|
.catch((error) => {
|
|
113
156
|
if (requestId !== this.askRequestId) return;
|
|
@@ -116,6 +159,7 @@ export class ExplanationController {
|
|
|
116
159
|
status: "error",
|
|
117
160
|
message: error instanceof Error ? error.message : String(error),
|
|
118
161
|
};
|
|
162
|
+
this.onAskChanged?.(this.askState);
|
|
119
163
|
})
|
|
120
164
|
.finally(() => {
|
|
121
165
|
if (requestId !== this.askRequestId) return;
|
|
@@ -134,6 +178,7 @@ export class ExplanationController {
|
|
|
134
178
|
this.askAbortController?.abort();
|
|
135
179
|
this.askAbortController = undefined;
|
|
136
180
|
this.askState = undefined;
|
|
181
|
+
this.onAskChanged?.(undefined);
|
|
137
182
|
}
|
|
138
183
|
|
|
139
184
|
ensure(scope: ExplanationScope | undefined): void {
|
|
@@ -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,139 +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
20
|
ReviewComment,
|
|
21
|
+
ReviewLine,
|
|
14
22
|
ReviewResult,
|
|
15
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";
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
type DiffReviewCacheEntry = {
|
|
21
|
-
cacheKey: string;
|
|
22
|
-
comments: ReviewComment[];
|
|
23
|
-
updatedAt: number;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type DiffExplanationCacheEntry = {
|
|
27
|
-
cacheKey: string;
|
|
28
|
-
explanations: Record<string, string>;
|
|
29
|
-
updatedAt: number;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
function getDiffCacheKey(
|
|
33
|
-
cwd: string,
|
|
34
|
-
source: DiffSource,
|
|
35
|
-
diffText: string,
|
|
36
|
-
): string {
|
|
37
|
-
const hash = createHash("sha256").update(diffText).digest("hex");
|
|
38
|
-
return `${cwd}\0${source.label}\0${hash}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function getCachedComments(
|
|
42
|
-
ctx: ExtensionCommandContext,
|
|
43
|
-
cacheKey: string,
|
|
44
|
-
): Map<string, ReviewComment> {
|
|
45
|
-
let latest: DiffReviewCacheEntry | undefined;
|
|
46
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
47
|
-
if (
|
|
48
|
-
entry.type !== "custom" ||
|
|
49
|
-
entry.customType !== DIFF_REVIEW_CACHE_ENTRY
|
|
50
|
-
) {
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const data = entry.data as Partial<DiffReviewCacheEntry> | undefined;
|
|
55
|
-
if (data?.cacheKey !== cacheKey || !Array.isArray(data.comments)) {
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
60
|
-
latest = {
|
|
61
|
-
cacheKey: data.cacheKey,
|
|
62
|
-
comments: data.comments,
|
|
63
|
-
updatedAt: data.updatedAt ?? 0,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return new Map(
|
|
69
|
-
(latest?.comments ?? []).map((comment) => [comment.id, comment]),
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function persistCachedComments(
|
|
74
|
-
pi: ExtensionAPI,
|
|
75
|
-
cacheKey: string,
|
|
76
|
-
comments: Iterable<ReviewComment>,
|
|
77
|
-
): void {
|
|
78
|
-
pi.appendEntry(DIFF_REVIEW_CACHE_ENTRY, {
|
|
79
|
-
cacheKey,
|
|
80
|
-
comments: [...comments],
|
|
81
|
-
updatedAt: Date.now(),
|
|
82
|
-
} satisfies DiffReviewCacheEntry);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function getCachedExplanations(
|
|
86
|
-
ctx: ExtensionCommandContext,
|
|
87
|
-
cacheKey: string,
|
|
88
|
-
): Map<string, string> {
|
|
89
|
-
let latest: DiffExplanationCacheEntry | undefined;
|
|
90
|
-
for (const entry of ctx.sessionManager.getEntries()) {
|
|
91
|
-
if (
|
|
92
|
-
entry.type !== "custom" ||
|
|
93
|
-
entry.customType !== DIFF_REVIEW_EXPLANATION_CACHE_ENTRY
|
|
94
|
-
) {
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const data = entry.data as Partial<DiffExplanationCacheEntry> | undefined;
|
|
99
|
-
if (
|
|
100
|
-
data?.cacheKey !== cacheKey ||
|
|
101
|
-
!data.explanations ||
|
|
102
|
-
typeof data.explanations !== "object" ||
|
|
103
|
-
Array.isArray(data.explanations)
|
|
104
|
-
) {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (!latest || (data.updatedAt ?? 0) >= latest.updatedAt) {
|
|
109
|
-
latest = {
|
|
110
|
-
cacheKey: data.cacheKey,
|
|
111
|
-
explanations: Object.fromEntries(
|
|
112
|
-
Object.entries(data.explanations).filter(
|
|
113
|
-
(entry): entry is [string, string] =>
|
|
114
|
-
typeof entry[0] === "string" && typeof entry[1] === "string",
|
|
115
|
-
),
|
|
116
|
-
),
|
|
117
|
-
updatedAt: data.updatedAt ?? 0,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return new Map(Object.entries(latest?.explanations ?? {}));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function persistCachedExplanations(
|
|
126
|
-
pi: ExtensionAPI,
|
|
127
|
-
cacheKey: string,
|
|
128
|
-
explanations: Map<string, string>,
|
|
129
|
-
): void {
|
|
130
|
-
pi.appendEntry(DIFF_REVIEW_EXPLANATION_CACHE_ENTRY, {
|
|
131
|
-
cacheKey,
|
|
132
|
-
explanations: Object.fromEntries(explanations),
|
|
133
|
-
updatedAt: Date.now(),
|
|
134
|
-
} satisfies DiffExplanationCacheEntry);
|
|
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}`;
|
|
135
31
|
}
|
|
136
32
|
|
|
137
33
|
export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
138
34
|
pi.registerCommand("diff", {
|
|
139
35
|
description: "Review a git diff in a custom TUI (/diff [git diff args])",
|
|
140
36
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
141
|
-
let source
|
|
37
|
+
let source;
|
|
142
38
|
let diffText: string;
|
|
143
39
|
try {
|
|
144
40
|
source = parseDiffSource(args);
|
|
@@ -155,54 +51,168 @@ export function registerDiffReviewCommand(pi: ExtensionAPI): void {
|
|
|
155
51
|
}
|
|
156
52
|
|
|
157
53
|
const reviewLines = parseDiff(diffText);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
166
80
|
}
|
|
167
|
-
|
|
81
|
+
|
|
82
|
+
if (files.length === 0) {
|
|
168
83
|
ctx.ui.notify(
|
|
169
|
-
|
|
84
|
+
"No viewable text files matched the requested paths.",
|
|
170
85
|
"info",
|
|
171
86
|
);
|
|
87
|
+
return;
|
|
172
88
|
}
|
|
173
89
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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(),
|
|
191
194
|
);
|
|
192
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(),
|
|
193
205
|
);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
194
208
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
}
|
|
201
215
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
buildReviewPrompt(result.comments, source.promptLabel),
|
|
205
|
-
);
|
|
206
|
-
},
|
|
207
|
-
});
|
|
216
|
+
persistCachedComments(pi, options.cacheKey, []);
|
|
217
|
+
pi.sendUserMessage(options.buildPrompt(result.comments));
|
|
208
218
|
}
|