newpr 0.1.3 → 0.2.0

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.
Files changed (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. package/src/web/client/panels/SummaryPanel.tsx +0 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newpr",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "AI-powered large PR review tool - understand PRs with 1000+ lines of changes",
5
5
  "module": "src/cli/index.ts",
6
6
  "type": "module",
@@ -61,16 +61,26 @@
61
61
  "@radix-ui/react-slot": "^1.2.4",
62
62
  "@radix-ui/react-tabs": "^1.1.13",
63
63
  "@tailwindcss/cli": "^4.1.18",
64
+ "@tiptap/extension-mention": "^3.19.0",
65
+ "@tiptap/extension-placeholder": "^3.19.0",
66
+ "@tiptap/pm": "^3.19.0",
67
+ "@tiptap/react": "^3.19.0",
68
+ "@tiptap/starter-kit": "^3.19.0",
64
69
  "class-variance-authority": "^0.7.1",
65
70
  "clsx": "^2.1.1",
66
71
  "ink": "6.6.0",
67
72
  "ink-spinner": "5.0.0",
68
73
  "ink-text-input": "6.0.0",
74
+ "katex": "^0.16.28",
69
75
  "lucide-react": "^0.567.0",
70
76
  "react": "19.1.0",
71
77
  "react-dom": "19.1.0",
72
78
  "react-markdown": "^10.1.0",
79
+ "rehype-katex": "^7.0.1",
80
+ "rehype-raw": "^7.0.0",
73
81
  "remark-gfm": "^4.0.1",
82
+ "remark-math": "^6.0.0",
83
+ "shiki": "^3.22.0",
74
84
  "tailwind-merge": "^3.4.1",
75
85
  "tailwindcss": "^4.1.18",
76
86
  "tailwindcss-animate": "^1.0.7"
@@ -6,7 +6,7 @@ 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
8
  import { chunkDiff } from "../diff/chunker.ts";
9
- import { fetchPrData } from "../github/fetch-pr.ts";
9
+ import { fetchPrData, fetchPrComments } from "../github/fetch-pr.ts";
10
10
  import { fetchPrDiff } from "../github/fetch-diff.ts";
11
11
  import { createLlmClient, type LlmClient, type LlmResponse } from "../llm/client.ts";
12
12
  import {
@@ -50,6 +50,7 @@ interface PipelineOptions {
50
50
  token: string;
51
51
  config: NewprConfig;
52
52
  onProgress?: ProgressCallback;
53
+ onFilePatches?: (patches: Record<string, string>) => void;
53
54
  noClone?: boolean;
54
55
  preferredAgent?: AgentToolName;
55
56
  }
@@ -158,12 +159,13 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
158
159
  timeout: config.timeout,
159
160
  });
160
161
 
161
- progress({ stage: "fetching", message: "Fetching PR data and diff..." });
162
- const [prData, rawDiff] = await Promise.all([
162
+ progress({ stage: "fetching", message: "Fetching PR data, diff, and discussion..." });
163
+ const [prData, rawDiff, prComments] = await Promise.all([
163
164
  fetchPrData(pr, token),
164
165
  fetchPrDiff(pr, token),
166
+ fetchPrComments(pr, token).catch(() => []),
165
167
  ]);
166
- progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions}` });
168
+ progress({ stage: "fetching", message: `#${prData.number} "${prData.title}" by ${prData.author} · +${prData.additions} −${prData.deletions} · ${prComments.length} comments` });
167
169
 
168
170
  progress({ stage: "parsing", message: "Parsing diff..." });
169
171
  const parsed = parseDiff(rawDiff);
@@ -175,6 +177,15 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
175
177
  const totalDel = chunks.reduce((s, c) => s + c.deletions, 0);
176
178
  progress({ stage: "parsing", message: `${chunks.length} files · +${totalAdd} −${totalDel}${wasTruncated ? ` (${allChunks.length - config.max_files} skipped)` : ""}` });
177
179
 
180
+ const changedFilesSet = new Set(changedFiles);
181
+ const filePatches: Record<string, string> = {};
182
+ for (const fileDiff of parsed.files) {
183
+ if (changedFilesSet.has(fileDiff.path)) {
184
+ filePatches[fileDiff.path] = fileDiff.raw;
185
+ }
186
+ }
187
+ options.onFilePatches?.(filePatches);
188
+
178
189
  let exploration: ExplorationResult | null = null;
179
190
  if (!noClone) {
180
191
  exploration = await tryExploreCodebase(
@@ -183,7 +194,12 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
183
194
  );
184
195
  }
185
196
 
186
- const promptCtx: PromptContext = { commits: prData.commits, language: config.language };
197
+ const promptCtx: PromptContext = {
198
+ commits: prData.commits,
199
+ language: config.language,
200
+ prBody: prData.body,
201
+ discussion: prComments.map((c) => ({ author: c.author, body: c.body })),
202
+ };
187
203
  const enrichedTag = exploration ? " + codebase context" : "";
188
204
 
189
205
  progress({
@@ -279,6 +295,7 @@ export async function analyzePr(options: PipelineOptions): Promise<NewprOutput>
279
295
  meta: {
280
296
  pr_number: prData.number,
281
297
  pr_title: prData.title,
298
+ pr_body: prData.body || undefined,
282
299
  pr_url: prData.url,
283
300
  base_branch: prData.base_branch,
284
301
  head_branch: prData.head_branch,
@@ -1,4 +1,4 @@
1
- import type { GithubPrData, PrCommit, PrIdentifier } from "../types/github.ts";
1
+ import type { GithubPrData, PrComment, PrCommit, PrIdentifier } from "../types/github.ts";
2
2
 
3
3
  export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData, "commits"> {
4
4
  const user = json.user as Record<string, unknown> | undefined;
@@ -8,6 +8,7 @@ export function mapPrResponse(json: Record<string, unknown>): Omit<GithubPrData,
8
8
  return {
9
9
  number: json.number as number,
10
10
  title: json.title as string,
11
+ body: (json.body as string) ?? "",
11
12
  url: json.html_url as string,
12
13
  base_branch: (base?.ref as string) ?? "unknown",
13
14
  head_branch: (head?.ref as string) ?? "unknown",
@@ -88,3 +89,44 @@ export async function fetchPrData(pr: PrIdentifier, token: string): Promise<Gith
88
89
 
89
90
  return { ...base, commits };
90
91
  }
92
+
93
+ interface GithubCommentResponse {
94
+ id: number;
95
+ user: { login: string; avatar_url?: string } | null;
96
+ body: string;
97
+ created_at: string;
98
+ updated_at: string;
99
+ html_url: string;
100
+ }
101
+
102
+ export async function fetchPrComments(pr: PrIdentifier, token: string): Promise<PrComment[]> {
103
+ const allComments: GithubCommentResponse[] = [];
104
+ let page = 1;
105
+
106
+ while (true) {
107
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/issues/${pr.number}/comments?per_page=100&page=${page}`;
108
+ const response = await githubGet(url, token);
109
+ const items = (await response.json()) as GithubCommentResponse[];
110
+ if (items.length === 0) break;
111
+ allComments.push(...items);
112
+ if (items.length < 100) break;
113
+ page++;
114
+ }
115
+
116
+ return allComments.map((c) => ({
117
+ id: c.id,
118
+ author: c.user?.login ?? "unknown",
119
+ author_avatar: c.user?.avatar_url ?? undefined,
120
+ body: c.body,
121
+ created_at: c.created_at,
122
+ updated_at: c.updated_at,
123
+ html_url: c.html_url,
124
+ }));
125
+ }
126
+
127
+ export async function fetchPrBody(pr: PrIdentifier, token: string): Promise<string> {
128
+ const url = `https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`;
129
+ const response = await githubGet(url, token);
130
+ const json = (await response.json()) as Record<string, unknown>;
131
+ return (json.body as string) ?? "";
132
+ }
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { mkdirSync, rmSync, existsSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
- import type { NewprOutput } from "../types/output.ts";
5
+ import type { NewprOutput, DiffComment, ChatMessage, CartoonImage } from "../types/output.ts";
6
6
  import type { SessionRecord } from "./types.ts";
7
7
 
8
8
  const HISTORY_DIR = join(homedir(), ".newpr", "history");
@@ -91,6 +91,111 @@ export async function clearHistory(): Promise<void> {
91
91
  }
92
92
  }
93
93
 
94
+ export async function savePatchesSidecar(
95
+ id: string,
96
+ patches: Record<string, string>,
97
+ ): Promise<void> {
98
+ ensureDirs();
99
+ await Bun.write(
100
+ join(SESSIONS_DIR, `${id}.patches.json`),
101
+ JSON.stringify(patches),
102
+ );
103
+ }
104
+
105
+ export async function loadPatchesSidecar(
106
+ id: string,
107
+ ): Promise<Record<string, string> | null> {
108
+ try {
109
+ const filePath = join(SESSIONS_DIR, `${id}.patches.json`);
110
+ const file = Bun.file(filePath);
111
+ if (!(await file.exists())) return null;
112
+ return JSON.parse(await file.text()) as Record<string, string>;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ export async function loadSinglePatch(
119
+ id: string,
120
+ filePath: string,
121
+ ): Promise<string | null> {
122
+ const patches = await loadPatchesSidecar(id);
123
+ if (!patches) return null;
124
+ return patches[filePath] ?? null;
125
+ }
126
+
127
+ export async function saveCommentsSidecar(
128
+ id: string,
129
+ comments: DiffComment[],
130
+ ): Promise<void> {
131
+ ensureDirs();
132
+ await Bun.write(
133
+ join(SESSIONS_DIR, `${id}.comments.json`),
134
+ JSON.stringify(comments, null, 2),
135
+ );
136
+ }
137
+
138
+ export async function loadCommentsSidecar(
139
+ id: string,
140
+ ): Promise<DiffComment[] | null> {
141
+ try {
142
+ const filePath = join(SESSIONS_DIR, `${id}.comments.json`);
143
+ const file = Bun.file(filePath);
144
+ if (!(await file.exists())) return null;
145
+ return JSON.parse(await file.text()) as DiffComment[];
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ export async function saveChatSidecar(
152
+ id: string,
153
+ messages: ChatMessage[],
154
+ ): Promise<void> {
155
+ ensureDirs();
156
+ await Bun.write(
157
+ join(SESSIONS_DIR, `${id}.chat.json`),
158
+ JSON.stringify(messages, null, 2),
159
+ );
160
+ }
161
+
162
+ export async function loadChatSidecar(
163
+ id: string,
164
+ ): Promise<ChatMessage[] | null> {
165
+ try {
166
+ const filePath = join(SESSIONS_DIR, `${id}.chat.json`);
167
+ const file = Bun.file(filePath);
168
+ if (!(await file.exists())) return null;
169
+ return JSON.parse(await file.text()) as ChatMessage[];
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ export async function saveCartoonSidecar(
176
+ id: string,
177
+ cartoon: CartoonImage,
178
+ ): Promise<void> {
179
+ ensureDirs();
180
+ await Bun.write(
181
+ join(SESSIONS_DIR, `${id}.cartoon.json`),
182
+ JSON.stringify(cartoon),
183
+ );
184
+ }
185
+
186
+ export async function loadCartoonSidecar(
187
+ id: string,
188
+ ): Promise<CartoonImage | null> {
189
+ try {
190
+ const filePath = join(SESSIONS_DIR, `${id}.cartoon.json`);
191
+ const file = Bun.file(filePath);
192
+ if (!(await file.exists())) return null;
193
+ return JSON.parse(await file.text()) as CartoonImage;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
94
199
  export function getHistoryPath(): string {
95
200
  return HISTORY_DIR;
96
201
  }
package/src/llm/client.ts CHANGED
@@ -238,3 +238,200 @@ export function createLlmClient(options: LlmClientOptions): LlmClient {
238
238
  const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
239
239
  return create(options.timeout);
240
240
  }
241
+
242
+ export interface ChatTool {
243
+ type: "function";
244
+ function: {
245
+ name: string;
246
+ description: string;
247
+ parameters: Record<string, unknown>;
248
+ };
249
+ }
250
+
251
+ export interface ChatToolCallDelta {
252
+ id: string;
253
+ name: string;
254
+ arguments: string;
255
+ }
256
+
257
+ export interface ChatStreamEvent {
258
+ type: "text" | "tool_call" | "tool_result" | "done" | "error";
259
+ content?: string;
260
+ toolCall?: ChatToolCallDelta;
261
+ toolResult?: { id: string; result: string };
262
+ error?: string;
263
+ }
264
+
265
+ export type ChatStreamCallback = (event: ChatStreamEvent) => void;
266
+
267
+ interface OpenRouterChatMessage {
268
+ role: "system" | "user" | "assistant" | "tool";
269
+ content?: string | null;
270
+ tool_calls?: Array<{
271
+ id: string;
272
+ type: "function";
273
+ function: { name: string; arguments: string };
274
+ }>;
275
+ tool_call_id?: string;
276
+ }
277
+
278
+ interface OpenRouterStreamToolCallDelta {
279
+ index?: number;
280
+ id?: string;
281
+ type?: string;
282
+ function?: { name?: string; arguments?: string };
283
+ }
284
+
285
+ interface OpenRouterStreamChunkWithTools {
286
+ choices: Array<{
287
+ delta: {
288
+ content?: string;
289
+ tool_calls?: OpenRouterStreamToolCallDelta[];
290
+ };
291
+ finish_reason?: string | null;
292
+ }>;
293
+ model?: string;
294
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
295
+ }
296
+
297
+ export async function chatWithTools(
298
+ options: LlmClientOptions,
299
+ messages: OpenRouterChatMessage[],
300
+ tools: ChatTool[],
301
+ executeTool: (name: string, args: Record<string, unknown>) => Promise<string>,
302
+ onEvent: ChatStreamCallback,
303
+ ): Promise<void> {
304
+ const MAX_TOOL_ROUNDS = 10;
305
+
306
+ let currentMessages = [...messages];
307
+
308
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
309
+ const controller = new AbortController();
310
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
311
+
312
+ let response: Response;
313
+ try {
314
+ response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
315
+ method: "POST",
316
+ signal: controller.signal,
317
+ headers: {
318
+ Authorization: `Bearer ${options.api_key}`,
319
+ "Content-Type": "application/json",
320
+ "HTTP-Referer": "https://github.com/sionic/newpr",
321
+ "X-Title": "newpr",
322
+ },
323
+ body: JSON.stringify({
324
+ model: options.model,
325
+ messages: currentMessages,
326
+ tools: tools.length > 0 ? tools : undefined,
327
+ temperature: 0.3,
328
+ stream: true,
329
+ }),
330
+ });
331
+ } catch (err) {
332
+ clearTimeout(timeoutId);
333
+ onEvent({ type: "error", error: err instanceof Error ? err.message : String(err) });
334
+ return;
335
+ }
336
+
337
+ clearTimeout(timeoutId);
338
+
339
+ if (!response.ok) {
340
+ const body = await response.text();
341
+ onEvent({ type: "error", error: `OpenRouter API error ${response.status}: ${body}` });
342
+ return;
343
+ }
344
+
345
+ const reader = response.body!.getReader();
346
+ const decoder = new TextDecoder();
347
+ let buffer = "";
348
+ let textContent = "";
349
+ const toolCalls = new Map<number, { id: string; name: string; arguments: string }>();
350
+
351
+ try {
352
+ while (true) {
353
+ const { done, value } = await reader.read();
354
+ if (done) break;
355
+
356
+ buffer += decoder.decode(value, { stream: true });
357
+ const lines = buffer.split("\n");
358
+ buffer = lines.pop() ?? "";
359
+
360
+ for (const line of lines) {
361
+ const trimmed = line.trim();
362
+ if (!trimmed || trimmed === "data: [DONE]") continue;
363
+ if (!trimmed.startsWith("data: ")) continue;
364
+
365
+ try {
366
+ const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunkWithTools;
367
+ const delta = chunk.choices[0]?.delta;
368
+ if (!delta) continue;
369
+
370
+ if (delta.content) {
371
+ textContent += delta.content;
372
+ onEvent({ type: "text", content: delta.content });
373
+ }
374
+
375
+ if (delta.tool_calls) {
376
+ for (const tc of delta.tool_calls) {
377
+ const idx = tc.index ?? 0;
378
+ if (!toolCalls.has(idx)) {
379
+ toolCalls.set(idx, { id: tc.id ?? "", name: "", arguments: "" });
380
+ }
381
+ const entry = toolCalls.get(idx)!;
382
+ if (tc.id) entry.id = tc.id;
383
+ if (tc.function?.name) entry.name += tc.function.name;
384
+ if (tc.function?.arguments) entry.arguments += tc.function.arguments;
385
+ }
386
+ }
387
+ } catch {}
388
+ }
389
+ }
390
+ } finally {
391
+ reader.releaseLock();
392
+ }
393
+
394
+ if (toolCalls.size === 0) {
395
+ onEvent({ type: "done" });
396
+ return;
397
+ }
398
+
399
+ const assistantMsg: OpenRouterChatMessage = {
400
+ role: "assistant",
401
+ content: textContent || null,
402
+ tool_calls: [...toolCalls.values()].map((tc) => ({
403
+ id: tc.id,
404
+ type: "function" as const,
405
+ function: { name: tc.name, arguments: tc.arguments },
406
+ })),
407
+ };
408
+ currentMessages.push(assistantMsg);
409
+
410
+ for (const tc of toolCalls.values()) {
411
+ onEvent({
412
+ type: "tool_call",
413
+ toolCall: { id: tc.id, name: tc.name, arguments: tc.arguments },
414
+ });
415
+
416
+ let args: Record<string, unknown> = {};
417
+ try { args = JSON.parse(tc.arguments); } catch {}
418
+
419
+ let result: string;
420
+ try {
421
+ result = await executeTool(tc.name, args);
422
+ } catch (err) {
423
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
424
+ }
425
+
426
+ onEvent({ type: "tool_result", toolResult: { id: tc.id, result } });
427
+
428
+ currentMessages.push({
429
+ role: "tool",
430
+ content: result,
431
+ tool_call_id: tc.id,
432
+ });
433
+ }
434
+ }
435
+
436
+ onEvent({ type: "done" });
437
+ }
@@ -17,6 +17,8 @@ export interface FileSummaryInput {
17
17
  export interface PromptContext {
18
18
  commits?: PrCommit[];
19
19
  language?: string;
20
+ prBody?: string;
21
+ discussion?: Array<{ author: string; body: string }>;
20
22
  }
21
23
 
22
24
  function langDirective(lang?: string): string {
@@ -24,6 +26,21 @@ function langDirective(lang?: string): string {
24
26
  return `\nCRITICAL LANGUAGE RULE: ALL text values in your response MUST be written in ${lang}. This includes every summary, description, name, purpose, scope, and impact field. JSON keys stay in English, but ALL string values MUST be in ${lang}. Do NOT use English for any descriptive text.`;
25
27
  }
26
28
 
29
+ function formatDiscussion(ctx?: PromptContext): string {
30
+ const parts: string[] = [];
31
+ if (ctx?.prBody?.trim()) {
32
+ parts.push(`PR Description:\n${ctx.prBody.trim()}`);
33
+ }
34
+ if (ctx?.discussion && ctx.discussion.length > 0) {
35
+ const comments = ctx.discussion
36
+ .map((c) => `@${c.author}: ${c.body.length > 500 ? `${c.body.slice(0, 500)}…` : c.body}`)
37
+ .join("\n\n");
38
+ parts.push(`Discussion (${ctx.discussion.length} comments):\n${comments}`);
39
+ }
40
+ if (parts.length === 0) return "";
41
+ return `\n\n--- PR DISCUSSION ---\n${parts.join("\n\n")}`;
42
+ }
43
+
27
44
  function formatCommitHistory(commits: PrCommit[]): string {
28
45
  if (commits.length === 0) return "";
29
46
  const lines = commits.map((c) => {
@@ -44,13 +61,15 @@ export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext)
44
61
 
45
62
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
46
63
 
64
+ const discussionCtx = formatDiscussion(ctx);
65
+
47
66
  return {
48
67
  system: `You are an expert code reviewer. Analyze the given diff and provide a 1-line summary for each changed file.
49
- Use the commit history to understand the intent behind each change — why the change was made, not just what changed.
68
+ Use the commit history and PR discussion to understand the intent behind each change — why the change was made, not just what changed.
50
69
  Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
51
70
  The "path" value must be the exact file path. The "summary" value is a human-readable description.
52
71
  No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
53
- user: `${fileList}${commitCtx}`,
72
+ user: `${fileList}${commitCtx}${discussionCtx}`,
54
73
  };
55
74
  }
56
75
 
@@ -61,15 +80,17 @@ export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: Pro
61
80
 
62
81
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
63
82
 
83
+ const discussionCtx = formatDiscussion(ctx);
84
+
64
85
  return {
65
86
  system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
66
87
  Each group should have a descriptive name, a type (one of: feature, refactor, bugfix, chore, docs, test, config), a description, and a list of file paths.
67
88
  A file MAY appear in multiple groups if it serves multiple purposes (e.g., index.ts re-exporting for both a feature and a refactor).
68
- Use the commit history to understand which changes belong together logically.
89
+ Use the commit history and PR discussion to understand which changes belong together logically.
69
90
  Respond ONLY with a JSON array. Each element: {"name": "group name", "type": "feature|refactor|bugfix|chore|docs|test|config", "description": "what this group of changes does", "files": ["path1", "path2"]}.
70
91
  The "name" and "description" values are human-readable text. The "type" value must be one of the English keywords listed above. File paths stay as-is.
71
92
  Every file must appear in at least one group. No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
72
- user: `Changed files:\n${fileList}${commitCtx}`,
93
+ user: `Changed files:\n${fileList}${commitCtx}${discussionCtx}`,
73
94
  };
74
95
  }
75
96
 
@@ -86,13 +107,15 @@ export function buildOverallSummaryPrompt(
86
107
  const fileList = fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n");
87
108
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
88
109
 
110
+ const discussionCtx = formatDiscussion(ctx);
111
+
89
112
  return {
90
113
  system: `You are an expert code reviewer. Provide an overall summary of this Pull Request.
91
- Use the commit history to understand the development progression and intent.
114
+ Use the commit history and PR discussion to understand the development progression and intent. The PR description and reviewer comments provide essential context about why changes were made.
92
115
  Respond ONLY with a JSON object: {"purpose": "why this PR exists (1-2 sentences)", "scope": "what areas of code are affected", "impact": "what is the impact of these changes", "risk_level": "low|medium|high"}.
93
116
  The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
94
117
  No markdown, no explanation, just the JSON object.${langDirective(ctx?.language)}`,
95
- user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}`,
118
+ user: `PR Title: ${prTitle}\n\nChange Groups:\n${groupList}\n\nFile Summaries:\n${fileList}${commitCtx}${discussionCtx}`,
96
119
  };
97
120
  }
98
121
 
@@ -109,17 +132,19 @@ export function buildNarrativePrompt(
109
132
  const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
110
133
  const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
111
134
 
135
+ const discussionCtx = formatDiscussion(ctx);
136
+
112
137
  return {
113
138
  system: `You are an expert code reviewer writing a review walkthrough for other developers.
114
139
  Write a clear, concise narrative that tells the "story" of this PR — what changes were made and in what logical order.
115
- Use the commit history to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step.
140
+ Use the commit history and PR discussion to understand the development progression: which changes came first, how the PR evolved, and the intent behind each step. The PR description often explains the author's motivation and approach.
116
141
  Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
117
142
  ${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 [[group:...]]/[[file:...]] tokens.` : "If the PR title is in a non-English language, write the narrative in that same language."}
118
143
 
119
144
  IMPORTANT: When referencing a change group, wrap it as [[group:Group Name]]. When referencing a specific file, wrap it as [[file:path/to/file.ts]].
120
145
  Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
121
146
  Example: "The [[group:Auth Flow]] group introduces session management via [[file:src/auth/session.ts]] and [[file:src/auth/token.ts]]."`,
122
- 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}`,
147
+ 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}`,
123
148
  };
124
149
  }
125
150
 
package/src/tui/Shell.tsx CHANGED
@@ -7,7 +7,7 @@ import type { SessionRecord } from "../history/types.ts";
7
7
  import type { AgentToolName } from "../workspace/types.ts";
8
8
  import { parsePrInput } from "../github/parse-pr.ts";
9
9
  import { analyzePr } from "../analyzer/pipeline.ts";
10
- import { saveSession, listSessions, loadSession } from "../history/store.ts";
10
+ import { saveSession, savePatchesSidecar, listSessions, loadSession } from "../history/store.ts";
11
11
  import { detectAgents } from "../workspace/agent.ts";
12
12
  import { App } from "./App.tsx";
13
13
  import { InputBar } from "./InputBar.tsx";
@@ -94,10 +94,12 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
94
94
  setState({ phase: "loading", steps: [], startTime });
95
95
  setElapsed(0);
96
96
 
97
+ let capturedPatches: Record<string, string> = {};
97
98
  const result = await analyzePr({
98
99
  pr,
99
100
  token,
100
101
  config: liveConfig,
102
+ onFilePatches: (patches) => { capturedPatches = patches; },
101
103
  onProgress: (event: ProgressEvent) => {
102
104
  const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
103
105
  const prev = eventsRef.current;
@@ -117,7 +119,10 @@ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
117
119
  },
118
120
  });
119
121
 
120
- await saveSession(result);
122
+ const record = await saveSession(result);
123
+ if (Object.keys(capturedPatches).length > 0) {
124
+ await savePatchesSidecar(record.id, capturedPatches).catch(() => {});
125
+ }
121
126
  const updated = await listSessions(10);
122
127
  setSessions(updated);
123
128
 
@@ -12,9 +12,20 @@ export interface PrCommit {
12
12
  files: string[];
13
13
  }
14
14
 
15
+ export interface PrComment {
16
+ id: number;
17
+ author: string;
18
+ author_avatar?: string;
19
+ body: string;
20
+ created_at: string;
21
+ updated_at: string;
22
+ html_url: string;
23
+ }
24
+
15
25
  export interface GithubPrData {
16
26
  number: number;
17
27
  title: string;
28
+ body: string;
18
29
  url: string;
19
30
  base_branch: string;
20
31
  head_branch: string;