newpr 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/analyzer/pipeline.ts +41 -1
- package/src/cli/update-check.ts +20 -19
- package/src/config/store.ts +1 -0
- package/src/llm/prompts.ts +37 -17
- package/src/plugins/cartoon.ts +34 -0
- package/src/plugins/registry.ts +20 -0
- package/src/plugins/slides.ts +39 -0
- package/src/plugins/types.ts +33 -0
- package/src/web/client/App.tsx +2 -0
- package/src/web/client/components/AppShell.tsx +3 -1
- package/src/web/client/components/DetailPane.tsx +6 -4
- package/src/web/client/components/DiffViewer.tsx +56 -36
- package/src/web/client/components/Markdown.tsx +2 -2
- package/src/web/client/components/ResultsScreen.tsx +9 -5
- package/src/web/client/components/SettingsPanel.tsx +173 -21
- package/src/web/client/hooks/useFeatures.ts +8 -5
- package/src/web/client/lib/shiki.ts +29 -4
- package/src/web/server/routes.ts +222 -3
- package/src/web/server.ts +15 -0
- package/src/web/styles/built.css +1 -1
package/package.json
CHANGED
package/src/analyzer/pipeline.ts
CHANGED
|
@@ -5,6 +5,46 @@ import type { FileChange, FileGroup, NewprOutput, PrSummary } from "../types/out
|
|
|
5
5
|
import type { ExplorationResult } from "../workspace/types.ts";
|
|
6
6
|
import type { AgentToolName } from "../workspace/types.ts";
|
|
7
7
|
import { parseDiff } from "../diff/parser.ts";
|
|
8
|
+
|
|
9
|
+
function annotateDiffWithLineNumbers(rawDiff: string): string {
|
|
10
|
+
const lines = rawDiff.split("\n");
|
|
11
|
+
const result: string[] = [];
|
|
12
|
+
let oldNum = 0;
|
|
13
|
+
let newNum = 0;
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
17
|
+
if (hunkMatch) {
|
|
18
|
+
oldNum = Number(hunkMatch[1]);
|
|
19
|
+
newNum = Number(hunkMatch[2]);
|
|
20
|
+
result.push(line);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (line.startsWith("diff --git") || line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("\\")) {
|
|
25
|
+
result.push(line);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (line.startsWith("+")) {
|
|
30
|
+
result.push(`L${newNum} + ${line.slice(1)}`);
|
|
31
|
+
newNum++;
|
|
32
|
+
} else if (line.startsWith("-")) {
|
|
33
|
+
result.push(` - ${line.slice(1)}`);
|
|
34
|
+
oldNum++;
|
|
35
|
+
} else {
|
|
36
|
+
const text = line.startsWith(" ") ? line.slice(1) : line;
|
|
37
|
+
if (oldNum > 0 || newNum > 0) {
|
|
38
|
+
result.push(`L${newNum} ${text}`);
|
|
39
|
+
oldNum++;
|
|
40
|
+
newNum++;
|
|
41
|
+
} else {
|
|
42
|
+
result.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result.join("\n");
|
|
47
|
+
}
|
|
8
48
|
import { chunkDiff } from "../diff/chunker.ts";
|
|
9
49
|
import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
|
|
10
50
|
import { fetchPrDiff } from "../github/fetch-diff.ts";
|
|
@@ -257,7 +297,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
|
|
|
257
297
|
progress({ stage: "narrating", message: `Writing narrative${enrichedTag}...` });
|
|
258
298
|
const fileDiffs = chunks.slice(0, 30).map((c) => ({
|
|
259
299
|
path: c.file_path,
|
|
260
|
-
diff: c.diff_content.length > 3000 ?
|
|
300
|
+
diff: annotateDiffWithLineNumbers(c.diff_content.length > 3000 ? c.diff_content.slice(0, 3000) : c.diff_content),
|
|
261
301
|
}));
|
|
262
302
|
const narrativePrompt = exploration
|
|
263
303
|
? buildEnrichedNarrativePrompt(prData.title, summary, groups, exploration, promptCtx, fileDiffs)
|
package/src/cli/update-check.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const PACKAGE_NAME = "newpr";
|
|
2
|
-
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
|
3
2
|
|
|
4
3
|
interface UpdateInfo {
|
|
5
4
|
current: string;
|
|
@@ -7,27 +6,32 @@ interface UpdateInfo {
|
|
|
7
6
|
needsUpdate: boolean;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
interface CachedCheck {
|
|
10
|
+
latest: string;
|
|
11
|
+
checkedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readCache(): Promise<CachedCheck | null> {
|
|
11
15
|
try {
|
|
12
|
-
const file = Bun.file(`${process.env.HOME}/.newpr/
|
|
13
|
-
|
|
14
|
-
return
|
|
16
|
+
const file = Bun.file(`${process.env.HOME}/.newpr/update-cache.json`);
|
|
17
|
+
if (!(await file.exists())) return null;
|
|
18
|
+
return JSON.parse(await file.text()) as CachedCheck;
|
|
15
19
|
} catch {
|
|
16
|
-
return
|
|
20
|
+
return null;
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
async function
|
|
24
|
+
async function writeCache(latest: string): Promise<void> {
|
|
21
25
|
const dir = `${process.env.HOME}/.newpr`;
|
|
22
26
|
const { mkdirSync } = await import("node:fs");
|
|
23
27
|
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
24
|
-
await Bun.write(`${dir}/
|
|
28
|
+
await Bun.write(`${dir}/update-cache.json`, JSON.stringify({ latest, checkedAt: Date.now() }));
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
async function fetchLatestVersion(): Promise<string | null> {
|
|
28
32
|
try {
|
|
29
33
|
const controller = new AbortController();
|
|
30
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
34
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
31
35
|
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
32
36
|
signal: controller.signal,
|
|
33
37
|
headers: { Accept: "application/json" },
|
|
@@ -53,23 +57,20 @@ function compareVersions(current: string, latest: string): boolean {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
export async function checkForUpdate(currentVersion: string): Promise<UpdateInfo | null> {
|
|
56
|
-
const lastCheck = await getLastCheckTime();
|
|
57
|
-
if (Date.now() - lastCheck < CHECK_INTERVAL_MS) return null;
|
|
58
|
-
|
|
59
60
|
const latest = await fetchLatestVersion();
|
|
60
|
-
await
|
|
61
|
-
|
|
62
|
-
if (!latest) return null;
|
|
63
|
-
if (!compareVersions(currentVersion, latest)) return null;
|
|
61
|
+
if (latest) await writeCache(latest);
|
|
64
62
|
|
|
65
|
-
|
|
63
|
+
const version = latest ?? (await readCache())?.latest;
|
|
64
|
+
if (!version) return null;
|
|
65
|
+
if (!compareVersions(currentVersion, version)) return null;
|
|
66
|
+
return { current: currentVersion, latest: version, needsUpdate: true };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export function printUpdateNotice(info: UpdateInfo): void {
|
|
69
70
|
const msg = [
|
|
70
71
|
"",
|
|
71
|
-
` Update available: ${info.current} → \x1b[32m${info.latest}\x1b[0m`,
|
|
72
|
-
`
|
|
72
|
+
` \x1b[33m⚡\x1b[0m Update available: \x1b[2m${info.current}\x1b[0m → \x1b[32m${info.latest}\x1b[0m`,
|
|
73
|
+
` Run \x1b[36mbun add -g ${PACKAGE_NAME}\x1b[0m to update`,
|
|
73
74
|
"",
|
|
74
75
|
].join("\n");
|
|
75
76
|
process.stderr.write(msg);
|
package/src/config/store.ts
CHANGED
package/src/llm/prompts.ts
CHANGED
|
@@ -145,7 +145,12 @@ export function buildNarrativePrompt(
|
|
|
145
145
|
.join("\n\n");
|
|
146
146
|
|
|
147
147
|
const diffContext = fileDiffs && fileDiffs.length > 0
|
|
148
|
-
? `\n\n--- FILE DIFFS (use
|
|
148
|
+
? `\n\n--- FILE DIFFS (LINE NUMBERS ARE PRE-COMPUTED — use them directly for [[line:...]] anchors) ---
|
|
149
|
+
Each line is prefixed with its new-file line number:
|
|
150
|
+
"L42 + code" = added line at L42
|
|
151
|
+
" - code" = removed line (no new line number)
|
|
152
|
+
"L42 code" = unchanged context at L42
|
|
153
|
+
Use the L-numbers EXACTLY as shown. Do NOT compute line numbers yourself.\n\n${fileDiffs.map((f) => `File: ${f.path}\n${f.diff}`).join("\n\n---\n\n")}`
|
|
149
154
|
: "";
|
|
150
155
|
|
|
151
156
|
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
@@ -178,6 +183,7 @@ There are THREE anchor types. You MUST use ALL of them.
|
|
|
178
183
|
- Format: [[group:Exact Group Name]]
|
|
179
184
|
- Renders as a clickable blue chip.
|
|
180
185
|
- You MUST reference EVERY group from the Change Groups list at least once. No exceptions.
|
|
186
|
+
- Use the EXACT group name ONLY — do NOT append the type in parentheses. Write [[group:Auth Flow]], NOT [[group:Auth Flow (refactor)]].
|
|
181
187
|
- Use group anchors when introducing a topic area or explaining what a set of changes accomplishes together.
|
|
182
188
|
- Example: "The [[group:Auth Flow]] group introduces session management."
|
|
183
189
|
|
|
@@ -196,24 +202,37 @@ There are THREE anchor types. You MUST use ALL of them.
|
|
|
196
202
|
### Usage Rules:
|
|
197
203
|
- ALWAYS use [[line:path#Lstart-Lend]](text) with BOTH start and end lines. Single lines: [[line:path#L42-L42]](text).
|
|
198
204
|
- The (text) must describe WHAT the code does, not WHERE it is. Bad: "lines 42-50". Good: "the new rate limiter middleware".
|
|
199
|
-
- Wrap EVERY specific code mention in a line anchor. If you mention a function, class, type, constant, config change, or import — anchor it.
|
|
200
|
-
- Interleave anchors naturally within sentences. They should feel like hyperlinks in a wiki article.
|
|
201
205
|
- Do NOT pair [[file:...]] with [[line:...]] for the same file. The line anchor already opens the file.
|
|
202
206
|
- Use the diff context provided to find accurate line numbers. If unsure of exact lines, use [[file:...]] instead.
|
|
203
207
|
|
|
204
|
-
###
|
|
205
|
-
When describing a function or class, use anchors at TWO granularity levels:
|
|
208
|
+
### ANCHOR DENSITY — THIS IS THE MOST IMPORTANT RULE
|
|
206
209
|
|
|
207
|
-
|
|
208
|
-
**Level 2 — Implementation details**: When explaining what the code does, anchor EACH distinct piece of logic to its specific lines within the function.
|
|
210
|
+
Every sentence that describes code MUST contain at least one [[line:...]](...) anchor. A sentence without an anchor is a FAILURE.
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
Think of this like writing a Wikipedia article: almost every claim links to its source. In your narrative, the "source" is the specific line range in the diff.
|
|
213
|
+
|
|
214
|
+
**What MUST be anchored:**
|
|
215
|
+
- Every function, method, or class mentioned → anchor its declaration
|
|
216
|
+
- Every implementation detail (what a function does) → anchor the specific lines
|
|
217
|
+
- Every type, interface, or schema → anchor its definition
|
|
218
|
+
- Every config change, constant, or environment variable → anchor it
|
|
219
|
+
- Every import, export, or wiring between modules → anchor it
|
|
220
|
+
- Every conditional logic, error handling, or edge case → anchor the specific branch
|
|
221
|
+
- Every before/after comparison → anchor both the old and new code
|
|
222
|
+
|
|
223
|
+
**Two-level anchoring for functions:**
|
|
224
|
+
- Level 1: Anchor the function/class NAME to its full range (e.g., L15-L50)
|
|
225
|
+
- Level 2: Inside the same paragraph, anchor EACH logical step to its sub-range (e.g., L18-L22, L24-L30, L32-L40)
|
|
226
|
+
- A function description without Level 2 sub-anchors is TOO SPARSE
|
|
227
|
+
|
|
228
|
+
**Target density: 3-6 line anchors per paragraph.** If a paragraph has fewer than 2 line anchors, you are not anchoring enough.
|
|
229
|
+
|
|
230
|
+
Example (CORRECT density — 5 anchors in one paragraph):
|
|
211
231
|
"[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles the full JWT lifecycle. It [[line:src/auth/session.ts#L18-L22]](extracts the token from the Authorization header), [[line:src/auth/session.ts#L24-L30]](verifies the signature against the configured secret), and [[line:src/auth/session.ts#L32-L40]](checks the expiration timestamp). If validation fails, [[line:src/auth/session.ts#L42-L48]](it throws a typed AuthError with a specific error code)."
|
|
212
232
|
|
|
213
|
-
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
- Descriptive text for sub-anchors should explain the step, not name the function again.
|
|
233
|
+
Example (TOO SPARSE — only 1 anchor, rest is unlinked prose):
|
|
234
|
+
"[[line:src/auth/session.ts#L15-L50]](The validateToken function) handles JWT parsing. It extracts the token, verifies the signature, and checks expiration. If validation fails, it throws an error."
|
|
235
|
+
→ This is BAD because "extracts the token", "verifies the signature", "checks expiration", and "throws an error" should ALL be separate line anchors.
|
|
217
236
|
|
|
218
237
|
### Line Anchor Granularity:
|
|
219
238
|
- Anchor individual functions, not entire files: [[line:auth.ts#L15-L30]](validateToken) not [[line:auth.ts#L1-L200]](auth module)
|
|
@@ -222,14 +241,15 @@ Key principles:
|
|
|
222
241
|
- Anchor imports and exports that wire things together: [[line:index.ts#L3-L3]](re-exported from the barrel file)
|
|
223
242
|
- For multi-part changes, anchor each part separately
|
|
224
243
|
|
|
225
|
-
GOOD example (
|
|
244
|
+
GOOD example (all 3 anchor types + high density):
|
|
226
245
|
"The [[group:Auth Flow]] group introduces session management. [[line:src/auth/session.ts#L15-L50]](The new validateToken function) handles JWT parsing: [[line:src/auth/session.ts#L18-L22]](it extracts the token from the header), then [[line:src/auth/session.ts#L24-L35]](verifies the signature and checks expiration). [[line:src/auth/middleware.ts#L8-L20]](The auth middleware) invokes it on every request, [[line:src/auth/middleware.ts#L15-L18]](rejecting invalid tokens with a 401). Supporting configuration lives in [[file:src/auth/constants.ts]]."
|
|
227
246
|
|
|
228
247
|
BAD examples:
|
|
229
|
-
-
|
|
230
|
-
- No anchors
|
|
231
|
-
- One big anchor
|
|
232
|
-
- Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]"
|
|
248
|
+
- Unanchored prose: "The function extracts the token, verifies the signature, and checks expiration." → MUST anchor EACH action
|
|
249
|
+
- No group anchors: "The auth changes introduce session management." → MUST use [[group:Auth Flow]]
|
|
250
|
+
- One big anchor: "[[line:session.ts#L15-L50]](The function extracts tokens, verifies signatures, and checks expiration)" → MUST split into sub-anchors
|
|
251
|
+
- Bare line anchor: "[[line:src/auth/session.ts#L15-L30]]" → MUST have (text) after it
|
|
252
|
+
- Low density paragraph: A paragraph with only 1 line anchor and 4+ sentences of plain text → MUST add more anchors
|
|
233
253
|
|
|
234
254
|
${lang ? `CRITICAL: Write the ENTIRE narrative in ${lang}. Every sentence must be in ${lang}. Do NOT use English except for code identifiers, file paths, and anchor tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}`,
|
|
235
255
|
user: `PR Title: ${prTitle}\n\nSummary:\n- Purpose: ${summary.purpose}\n- Scope: ${summary.scope}\n- Impact: ${summary.impact}\n- Risk: ${summary.risk_level}\n\nChange Groups:\n${groupDetails}${commitCtx}${discussionCtx}${diffContext}`,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
|
|
2
|
+
import type { CartoonImage } from "../types/output.ts";
|
|
3
|
+
import { generateCartoon } from "../llm/cartoon.ts";
|
|
4
|
+
import { saveCartoonSidecar, loadCartoonSidecar } from "../history/store.ts";
|
|
5
|
+
|
|
6
|
+
export const cartoonPlugin: GeneratorPlugin = {
|
|
7
|
+
id: "cartoon",
|
|
8
|
+
name: "Comic Strip",
|
|
9
|
+
description: "Generate a 4-panel comic strip that visualizes the key changes in this PR.",
|
|
10
|
+
icon: "Sparkles",
|
|
11
|
+
tabLabel: "Comic",
|
|
12
|
+
|
|
13
|
+
isAvailable: (ctx) => !!ctx.apiKey,
|
|
14
|
+
|
|
15
|
+
async generate(ctx: PluginContext, onProgress?: PluginProgressCallback): Promise<PluginResult> {
|
|
16
|
+
onProgress?.({ message: "Generating comic strip...", current: 0, total: 1 });
|
|
17
|
+
const result = await generateCartoon(ctx.apiKey, ctx.data, ctx.language);
|
|
18
|
+
const cartoon: CartoonImage = {
|
|
19
|
+
imageBase64: result.imageBase64,
|
|
20
|
+
mimeType: result.mimeType,
|
|
21
|
+
generatedAt: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
onProgress?.({ message: "Comic strip done", current: 1, total: 1 });
|
|
24
|
+
return { type: "cartoon", data: cartoon };
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async save(sessionId: string, data: unknown): Promise<void> {
|
|
28
|
+
await saveCartoonSidecar(sessionId, data as CartoonImage);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async load(sessionId: string): Promise<CartoonImage | null> {
|
|
32
|
+
return loadCartoonSidecar(sessionId);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GeneratorPlugin } from "./types.ts";
|
|
2
|
+
import { cartoonPlugin } from "./cartoon.ts";
|
|
3
|
+
import { slidesPlugin } from "./slides.ts";
|
|
4
|
+
|
|
5
|
+
const plugins: GeneratorPlugin[] = [
|
|
6
|
+
slidesPlugin,
|
|
7
|
+
cartoonPlugin,
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function getPlugin(id: string): GeneratorPlugin | undefined {
|
|
11
|
+
return plugins.find((p) => p.id === id);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getAllPlugins(): GeneratorPlugin[] {
|
|
15
|
+
return plugins;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getPluginIds(): string[] {
|
|
19
|
+
return plugins.map((p) => p.id);
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GeneratorPlugin, PluginContext, PluginProgressCallback, PluginResult } from "./types.ts";
|
|
2
|
+
import type { SlideDeck } from "../types/output.ts";
|
|
3
|
+
import { generateSlides } from "../llm/slides.ts";
|
|
4
|
+
import { saveSlidesSidecar, loadSlidesSidecar } from "../history/store.ts";
|
|
5
|
+
|
|
6
|
+
export const slidesPlugin: GeneratorPlugin = {
|
|
7
|
+
id: "slides",
|
|
8
|
+
name: "Slide Deck",
|
|
9
|
+
description: "Generate a presentation that explains this PR to your team.",
|
|
10
|
+
icon: "Presentation",
|
|
11
|
+
tabLabel: "Slides",
|
|
12
|
+
|
|
13
|
+
isAvailable: (ctx) => !!ctx.apiKey,
|
|
14
|
+
|
|
15
|
+
async generate(ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown): Promise<PluginResult> {
|
|
16
|
+
const existing = existingData as SlideDeck | null | undefined;
|
|
17
|
+
const deck = await generateSlides(
|
|
18
|
+
ctx.apiKey,
|
|
19
|
+
ctx.data,
|
|
20
|
+
undefined,
|
|
21
|
+
ctx.language,
|
|
22
|
+
(msg, current, total) => onProgress?.({ message: msg, current, total }),
|
|
23
|
+
existing,
|
|
24
|
+
undefined,
|
|
25
|
+
(partialDeck) => {
|
|
26
|
+
saveSlidesSidecar(ctx.sessionId, partialDeck).catch(() => {});
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
return { type: "slides", data: deck };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async save(sessionId: string, data: unknown): Promise<void> {
|
|
33
|
+
await saveSlidesSidecar(sessionId, data as SlideDeck);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async load(sessionId: string): Promise<SlideDeck | null> {
|
|
37
|
+
return loadSlidesSidecar(sessionId);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { NewprOutput } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
export interface PluginContext {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
data: NewprOutput;
|
|
7
|
+
language: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PluginProgressEvent {
|
|
11
|
+
message: string;
|
|
12
|
+
current: number;
|
|
13
|
+
total: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type PluginProgressCallback = (event: PluginProgressEvent) => void;
|
|
17
|
+
|
|
18
|
+
export interface PluginResult {
|
|
19
|
+
type: string;
|
|
20
|
+
data: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GeneratorPlugin {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
icon: string;
|
|
28
|
+
tabLabel: string;
|
|
29
|
+
isAvailable: (ctx: PluginContext) => boolean;
|
|
30
|
+
generate: (ctx: PluginContext, onProgress?: PluginProgressCallback, existingData?: unknown) => Promise<PluginResult>;
|
|
31
|
+
save: (sessionId: string, data: unknown) => Promise<void>;
|
|
32
|
+
load: (sessionId: string) => Promise<unknown | null>;
|
|
33
|
+
}
|
package/src/web/client/App.tsx
CHANGED
|
@@ -163,6 +163,7 @@ export function App() {
|
|
|
163
163
|
bgAnalyses={bgAnalyses.analyses}
|
|
164
164
|
onBgClick={handleBgClick}
|
|
165
165
|
onBgDismiss={bgAnalyses.dismiss}
|
|
166
|
+
onFeaturesChange={features.refresh}
|
|
166
167
|
>
|
|
167
168
|
{analysis.phase === "idle" && (
|
|
168
169
|
<InputScreen
|
|
@@ -188,6 +189,7 @@ export function App() {
|
|
|
188
189
|
sessionId={diffSessionId}
|
|
189
190
|
onTabChange={setActiveTab}
|
|
190
191
|
onReanalyze={(prUrl: string) => { analysis.start(prUrl); }}
|
|
192
|
+
enabledPlugins={features.enabledPlugins}
|
|
191
193
|
/>
|
|
192
194
|
)}
|
|
193
195
|
{analysis.phase === "error" && (
|
|
@@ -166,6 +166,7 @@ export function AppShell({
|
|
|
166
166
|
bgAnalyses,
|
|
167
167
|
onBgClick,
|
|
168
168
|
onBgDismiss,
|
|
169
|
+
onFeaturesChange,
|
|
169
170
|
children,
|
|
170
171
|
}: {
|
|
171
172
|
theme: Theme;
|
|
@@ -181,6 +182,7 @@ export function AppShell({
|
|
|
181
182
|
bgAnalyses?: BackgroundAnalysis[];
|
|
182
183
|
onBgClick?: (sessionId: string) => void;
|
|
183
184
|
onBgDismiss?: (sessionId: string) => void;
|
|
185
|
+
onFeaturesChange?: () => void;
|
|
184
186
|
children: React.ReactNode;
|
|
185
187
|
}) {
|
|
186
188
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
@@ -382,7 +384,7 @@ export function AppShell({
|
|
|
382
384
|
onKeyDown={(e) => { if (e.key === "Escape") setSettingsOpen(false); }}
|
|
383
385
|
/>
|
|
384
386
|
<div className="relative z-10 w-full max-w-lg max-h-[85vh] overflow-y-auto rounded-xl border bg-background p-6 shadow-lg">
|
|
385
|
-
<SettingsPanel onClose={() => setSettingsOpen(false)} />
|
|
387
|
+
<SettingsPanel onClose={() => setSettingsOpen(false)} onFeaturesChange={onFeaturesChange} />
|
|
386
388
|
</div>
|
|
387
389
|
</div>
|
|
388
390
|
)}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { Plus, Pencil, Trash2, ArrowRight, X, Loader2, AlertCircle } from "lucide-react";
|
|
3
3
|
import type { FileGroup, FileChange, FileStatus } from "../../../types/output.ts";
|
|
4
4
|
import { DiffViewer } from "./DiffViewer.tsx";
|
|
@@ -43,7 +43,8 @@ export function resolveDetail(
|
|
|
43
43
|
files: FileChange[],
|
|
44
44
|
): DetailTarget | null {
|
|
45
45
|
if (kind === "group") {
|
|
46
|
-
const
|
|
46
|
+
const cleanId = id.replace(/\s*\([^)]*\)\s*$/, "").trim();
|
|
47
|
+
const group = groups.find((g) => g.name === id || g.name === cleanId || g.name.toLowerCase() === cleanId.toLowerCase());
|
|
47
48
|
if (!group) return null;
|
|
48
49
|
const groupFiles = files.filter((f) => group.files.includes(f.path));
|
|
49
50
|
return { kind: "group", group, files: groupFiles };
|
|
@@ -115,6 +116,7 @@ function FileDetail({
|
|
|
115
116
|
}) {
|
|
116
117
|
const Icon = STATUS_ICON[file.status];
|
|
117
118
|
const { patch, loading, error, fetchPatch } = usePatchFetcher(sessionId, file.path);
|
|
119
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
118
120
|
|
|
119
121
|
useEffect(() => {
|
|
120
122
|
if (sessionId && !patch && !loading && !error) {
|
|
@@ -140,7 +142,7 @@ function FileDetail({
|
|
|
140
142
|
</div>
|
|
141
143
|
</div>
|
|
142
144
|
|
|
143
|
-
<div className="flex-1 overflow-y-auto">
|
|
145
|
+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
|
|
144
146
|
{loading && (
|
|
145
147
|
<div className="flex items-center justify-center py-16 gap-2">
|
|
146
148
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
|
|
@@ -164,13 +166,13 @@ function FileDetail({
|
|
|
164
166
|
)}
|
|
165
167
|
{patch && (
|
|
166
168
|
<DiffViewer
|
|
167
|
-
key={`${file.path}-${scrollToLine ?? 0}-${scrollToLineEnd ?? 0}`}
|
|
168
169
|
patch={patch}
|
|
169
170
|
filePath={file.path}
|
|
170
171
|
sessionId={sessionId}
|
|
171
172
|
githubUrl={prUrl ? `${prUrl}/files` : undefined}
|
|
172
173
|
scrollToLine={scrollToLine}
|
|
173
174
|
scrollToLineEnd={scrollToLineEnd}
|
|
175
|
+
scrollContainerRef={scrollContainerRef}
|
|
174
176
|
/>
|
|
175
177
|
)}
|
|
176
178
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from "react";
|
|
2
2
|
import { type Highlighter, type ThemedToken } from "shiki";
|
|
3
|
-
import {
|
|
3
|
+
import { Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X, Sparkles, Loader2 } from "lucide-react";
|
|
4
4
|
import { ensureHighlighter, getHighlighterSync, detectShikiLang, type ShikiLang } from "../lib/shiki.ts";
|
|
5
5
|
import type { DiffComment } from "../../../types/output.ts";
|
|
6
6
|
import { TipTapEditor } from "./TipTapEditor.tsx";
|
|
@@ -18,7 +18,8 @@ const RENDER_CAP = 2000;
|
|
|
18
18
|
const TOTAL_CAP = 3000;
|
|
19
19
|
|
|
20
20
|
function parseLines(patch: string): DiffLine[] {
|
|
21
|
-
|
|
21
|
+
let raw = patch.split("\n");
|
|
22
|
+
while (raw.length > 0 && raw[raw.length - 1] === "") raw.pop();
|
|
22
23
|
const lines: DiffLine[] = [];
|
|
23
24
|
let oldNum = 0;
|
|
24
25
|
let newNum = 0;
|
|
@@ -61,6 +62,8 @@ function parseLines(patch: string): DiffLine[] {
|
|
|
61
62
|
oldNum++;
|
|
62
63
|
} else if (line.startsWith("\\")) {
|
|
63
64
|
lines.push({ type: "context", content: line, oldNum: null, newNum: null });
|
|
65
|
+
} else if (line === "" && (oldNum === 0 && newNum === 0)) {
|
|
66
|
+
continue;
|
|
64
67
|
} else {
|
|
65
68
|
const text = line.startsWith(" ") ? line.slice(1) : line;
|
|
66
69
|
if (oldNum > 0 || newNum > 0) {
|
|
@@ -107,27 +110,48 @@ function useTokenizedLines(
|
|
|
107
110
|
return useMemo(() => {
|
|
108
111
|
if (!hl || !lang) return null;
|
|
109
112
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
113
|
+
const newIndices: number[] = [];
|
|
114
|
+
const newLines: string[] = [];
|
|
115
|
+
const oldIndices: number[] = [];
|
|
116
|
+
const oldLines: string[] = [];
|
|
117
|
+
|
|
112
118
|
for (let i = 0; i < lines.length; i++) {
|
|
113
119
|
const t = lines[i]!.type;
|
|
114
|
-
if (t === "added" || t === "
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
if (t === "added" || t === "context") {
|
|
121
|
+
newIndices.push(i);
|
|
122
|
+
newLines.push(lines[i]!.content);
|
|
123
|
+
}
|
|
124
|
+
if (t === "removed") {
|
|
125
|
+
oldIndices.push(i);
|
|
126
|
+
oldLines.push(lines[i]!.content);
|
|
127
|
+
}
|
|
128
|
+
if (t === "context") {
|
|
129
|
+
oldIndices.push(i);
|
|
130
|
+
oldLines.push(lines[i]!.content);
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
133
|
|
|
120
|
-
|
|
134
|
+
const map: TokenMap = new Map();
|
|
135
|
+
const theme = dark ? "github-dark" : "github-light";
|
|
121
136
|
|
|
122
137
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
if (newLines.length > 0) {
|
|
139
|
+
const result = hl.codeToTokens(newLines.join("\n"), { lang, theme });
|
|
140
|
+
for (let j = 0; j < newIndices.length; j++) {
|
|
141
|
+
const tokens = result.tokens[j];
|
|
142
|
+
if (tokens) map.set(newIndices[j]!, tokens);
|
|
143
|
+
}
|
|
129
144
|
}
|
|
130
|
-
|
|
145
|
+
if (oldLines.length > 0) {
|
|
146
|
+
const result = hl.codeToTokens(oldLines.join("\n"), { lang, theme });
|
|
147
|
+
for (let j = 0; j < oldIndices.length; j++) {
|
|
148
|
+
if (!map.has(oldIndices[j]!)) {
|
|
149
|
+
const tokens = result.tokens[j];
|
|
150
|
+
if (tokens) map.set(oldIndices[j]!, tokens);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return map.size > 0 ? map : null;
|
|
131
155
|
} catch {
|
|
132
156
|
return null;
|
|
133
157
|
}
|
|
@@ -579,6 +603,7 @@ export function DiffViewer({
|
|
|
579
603
|
githubUrl,
|
|
580
604
|
scrollToLine,
|
|
581
605
|
scrollToLineEnd,
|
|
606
|
+
scrollContainerRef,
|
|
582
607
|
}: {
|
|
583
608
|
patch: string;
|
|
584
609
|
filePath: string;
|
|
@@ -586,6 +611,7 @@ export function DiffViewer({
|
|
|
586
611
|
githubUrl?: string;
|
|
587
612
|
scrollToLine?: number;
|
|
588
613
|
scrollToLineEnd?: number;
|
|
614
|
+
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
|
589
615
|
}) {
|
|
590
616
|
const [showAll, setShowAll] = useState(false);
|
|
591
617
|
const hl = useHighlighter();
|
|
@@ -595,15 +621,18 @@ export function DiffViewer({
|
|
|
595
621
|
const tokenMap = useTokenizedLines(hl, allLines, lang, dark);
|
|
596
622
|
const isCapped = !showAll && allLines.length > TOTAL_CAP;
|
|
597
623
|
const lines = isCapped ? allLines.slice(0, RENDER_CAP) : allLines;
|
|
598
|
-
const fileName = filePath.split("/").pop() ?? filePath;
|
|
599
624
|
|
|
600
625
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
601
626
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
602
627
|
const [visibleWidth, setVisibleWidth] = useState(0);
|
|
603
628
|
|
|
604
629
|
const highlightedRef = useRef<HTMLElement[]>([]);
|
|
630
|
+
const scrollKeyRef = useRef(0);
|
|
605
631
|
|
|
606
632
|
useEffect(() => {
|
|
633
|
+
scrollKeyRef.current++;
|
|
634
|
+
const currentKey = scrollKeyRef.current;
|
|
635
|
+
|
|
607
636
|
for (const el of highlightedRef.current) {
|
|
608
637
|
el.style.boxShadow = "";
|
|
609
638
|
}
|
|
@@ -612,6 +641,7 @@ export function DiffViewer({
|
|
|
612
641
|
if (!scrollToLine || !containerRef.current) return;
|
|
613
642
|
const endLine = scrollToLineEnd ?? scrollToLine;
|
|
614
643
|
const timer = setTimeout(() => {
|
|
644
|
+
if (scrollKeyRef.current !== currentKey) return;
|
|
615
645
|
const container = containerRef.current;
|
|
616
646
|
if (!container) return;
|
|
617
647
|
let scrollTarget: HTMLElement | null = null;
|
|
@@ -624,11 +654,14 @@ export function DiffViewer({
|
|
|
624
654
|
}
|
|
625
655
|
}
|
|
626
656
|
if (scrollTarget) {
|
|
627
|
-
let scrollParent =
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
657
|
+
let scrollParent: HTMLElement | null = scrollContainerRef?.current ?? null;
|
|
658
|
+
if (!scrollParent) {
|
|
659
|
+
scrollParent = scrollTarget.parentElement;
|
|
660
|
+
while (scrollParent) {
|
|
661
|
+
const style = getComputedStyle(scrollParent);
|
|
662
|
+
if (style.overflowY === "auto" || style.overflowY === "scroll") break;
|
|
663
|
+
scrollParent = scrollParent.parentElement;
|
|
664
|
+
}
|
|
632
665
|
}
|
|
633
666
|
if (scrollParent) {
|
|
634
667
|
const parentRect = scrollParent.getBoundingClientRect();
|
|
@@ -636,7 +669,7 @@ export function DiffViewer({
|
|
|
636
669
|
scrollParent.scrollTop += targetRect.top - parentRect.top - parentRect.height / 2;
|
|
637
670
|
}
|
|
638
671
|
}
|
|
639
|
-
},
|
|
672
|
+
}, 50);
|
|
640
673
|
return () => clearTimeout(timer);
|
|
641
674
|
}, [scrollToLine, scrollToLineEnd, patch]);
|
|
642
675
|
const [comments, setComments] = useState<DiffComment[]>([]);
|
|
@@ -774,21 +807,8 @@ export function DiffViewer({
|
|
|
774
807
|
.join("\n");
|
|
775
808
|
}, [formRange, lines]);
|
|
776
809
|
|
|
777
|
-
const commentCount = comments.length;
|
|
778
|
-
|
|
779
810
|
return (
|
|
780
|
-
<div ref={containerRef} className="
|
|
781
|
-
<div className="sticky top-0 z-10 bg-muted px-3 py-1.5 border-b flex items-center gap-2">
|
|
782
|
-
<span className="text-xs font-mono font-medium truncate flex-1" title={filePath}>
|
|
783
|
-
{fileName}
|
|
784
|
-
</span>
|
|
785
|
-
{commentCount > 0 && (
|
|
786
|
-
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
|
787
|
-
<MessageSquare className="h-3 w-3" />
|
|
788
|
-
{commentCount}
|
|
789
|
-
</span>
|
|
790
|
-
)}
|
|
791
|
-
</div>
|
|
811
|
+
<div ref={containerRef} className="overflow-hidden">
|
|
792
812
|
<div ref={scrollRef} className="overflow-x-auto">
|
|
793
813
|
<div className="min-w-max font-mono text-xs leading-5 select-text">
|
|
794
814
|
{lines.map((line, i) => {
|