newpr 0.1.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.
- package/README.md +189 -0
- package/package.json +78 -0
- package/src/analyzer/errors.ts +22 -0
- package/src/analyzer/pipeline.ts +299 -0
- package/src/analyzer/progress.ts +69 -0
- package/src/cli/args.ts +192 -0
- package/src/cli/auth.ts +82 -0
- package/src/cli/history-cmd.ts +64 -0
- package/src/cli/index.ts +115 -0
- package/src/cli/pretty.ts +79 -0
- package/src/config/index.ts +103 -0
- package/src/config/store.ts +50 -0
- package/src/diff/chunker.ts +30 -0
- package/src/diff/parser.ts +116 -0
- package/src/diff/stats.ts +37 -0
- package/src/github/auth.ts +16 -0
- package/src/github/fetch-diff.ts +24 -0
- package/src/github/fetch-pr.ts +90 -0
- package/src/github/parse-pr.ts +39 -0
- package/src/history/store.ts +96 -0
- package/src/history/types.ts +15 -0
- package/src/llm/claude-code-client.ts +134 -0
- package/src/llm/client.ts +240 -0
- package/src/llm/prompts.ts +176 -0
- package/src/llm/response-parser.ts +71 -0
- package/src/tui/App.tsx +97 -0
- package/src/tui/Footer.tsx +34 -0
- package/src/tui/Header.tsx +27 -0
- package/src/tui/HelpOverlay.tsx +46 -0
- package/src/tui/InputBar.tsx +65 -0
- package/src/tui/Loading.tsx +192 -0
- package/src/tui/Shell.tsx +384 -0
- package/src/tui/TabBar.tsx +31 -0
- package/src/tui/commands.ts +75 -0
- package/src/tui/narrative-parser.ts +143 -0
- package/src/tui/panels/FilesPanel.tsx +134 -0
- package/src/tui/panels/GroupsPanel.tsx +140 -0
- package/src/tui/panels/NarrativePanel.tsx +102 -0
- package/src/tui/panels/StoryPanel.tsx +296 -0
- package/src/tui/panels/SummaryPanel.tsx +59 -0
- package/src/tui/panels/WalkthroughPanel.tsx +149 -0
- package/src/tui/render.tsx +62 -0
- package/src/tui/theme.ts +44 -0
- package/src/types/config.ts +19 -0
- package/src/types/diff.ts +36 -0
- package/src/types/github.ts +28 -0
- package/src/types/output.ts +59 -0
- package/src/web/client/App.tsx +121 -0
- package/src/web/client/components/AppShell.tsx +203 -0
- package/src/web/client/components/DetailPane.tsx +141 -0
- package/src/web/client/components/ErrorScreen.tsx +119 -0
- package/src/web/client/components/InputScreen.tsx +41 -0
- package/src/web/client/components/LoadingTimeline.tsx +179 -0
- package/src/web/client/components/Markdown.tsx +109 -0
- package/src/web/client/components/ResizeHandle.tsx +45 -0
- package/src/web/client/components/ResultsScreen.tsx +185 -0
- package/src/web/client/components/SettingsPanel.tsx +299 -0
- package/src/web/client/hooks/useAnalysis.ts +153 -0
- package/src/web/client/hooks/useGithubUser.ts +24 -0
- package/src/web/client/hooks/useSessions.ts +17 -0
- package/src/web/client/hooks/useTheme.ts +34 -0
- package/src/web/client/main.tsx +12 -0
- package/src/web/client/panels/FilesPanel.tsx +85 -0
- package/src/web/client/panels/GroupsPanel.tsx +62 -0
- package/src/web/client/panels/NarrativePanel.tsx +9 -0
- package/src/web/client/panels/StoryPanel.tsx +54 -0
- package/src/web/client/panels/SummaryPanel.tsx +20 -0
- package/src/web/components/ui/button.tsx +46 -0
- package/src/web/components/ui/card.tsx +37 -0
- package/src/web/components/ui/scroll-area.tsx +39 -0
- package/src/web/components/ui/tabs.tsx +52 -0
- package/src/web/index.html +14 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/server/routes.ts +202 -0
- package/src/web/server/session-manager.ts +147 -0
- package/src/web/server.ts +96 -0
- package/src/web/styles/globals.css +91 -0
- package/src/workspace/agent.ts +317 -0
- package/src/workspace/explore.ts +82 -0
- package/src/workspace/repo-cache.ts +69 -0
- package/src/workspace/types.ts +30 -0
- package/src/workspace/worktree.ts +129 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
export interface LlmClientOptions {
|
|
2
|
+
api_key: string;
|
|
3
|
+
model: string;
|
|
4
|
+
timeout: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface LlmUsage {
|
|
8
|
+
prompt_tokens: number;
|
|
9
|
+
completion_tokens: number;
|
|
10
|
+
total_tokens: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LlmResponse {
|
|
14
|
+
content: string;
|
|
15
|
+
model: string;
|
|
16
|
+
usage: LlmUsage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type StreamChunkCallback = (chunk: string, accumulated: string) => void;
|
|
20
|
+
|
|
21
|
+
export interface LlmClient {
|
|
22
|
+
complete(systemPrompt: string, userPrompt: string): Promise<LlmResponse>;
|
|
23
|
+
completeStream(
|
|
24
|
+
systemPrompt: string,
|
|
25
|
+
userPrompt: string,
|
|
26
|
+
onChunk: StreamChunkCallback,
|
|
27
|
+
): Promise<LlmResponse>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OpenRouterResponse {
|
|
31
|
+
choices: Array<{ message: { content: string } }>;
|
|
32
|
+
model: string;
|
|
33
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface OpenRouterStreamChunk {
|
|
37
|
+
choices: Array<{ delta: { content?: string }; finish_reason?: string | null }>;
|
|
38
|
+
model?: string;
|
|
39
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MAX_RETRIES = 10;
|
|
43
|
+
const BASE_DELAY_MS = 1000;
|
|
44
|
+
const MAX_DELAY_MS = 30_000;
|
|
45
|
+
|
|
46
|
+
class NonRetriableError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "NonRetriableError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sleep(ms: number): Promise<void> {
|
|
54
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildRequestInit(
|
|
58
|
+
options: LlmClientOptions,
|
|
59
|
+
systemPrompt: string,
|
|
60
|
+
userPrompt: string,
|
|
61
|
+
stream: boolean,
|
|
62
|
+
signal: AbortSignal,
|
|
63
|
+
): RequestInit {
|
|
64
|
+
return {
|
|
65
|
+
method: "POST",
|
|
66
|
+
signal,
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${options.api_key}`,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
"HTTP-Referer": "https://github.com/sionic/newpr",
|
|
71
|
+
"X-Title": "newpr",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
model: options.model,
|
|
75
|
+
messages: [
|
|
76
|
+
{ role: "system", content: systemPrompt },
|
|
77
|
+
{ role: "user", content: userPrompt },
|
|
78
|
+
],
|
|
79
|
+
temperature: 0.1,
|
|
80
|
+
...(stream ? { stream: true } : {}),
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchWithRetry(
|
|
86
|
+
options: LlmClientOptions,
|
|
87
|
+
systemPrompt: string,
|
|
88
|
+
userPrompt: string,
|
|
89
|
+
stream: boolean,
|
|
90
|
+
): Promise<Response> {
|
|
91
|
+
let lastError: Error | null = null;
|
|
92
|
+
|
|
93
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
94
|
+
if (attempt > 0) {
|
|
95
|
+
const delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
|
|
96
|
+
await sleep(delay);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeoutId = setTimeout(() => controller.abort(), options.timeout * 1000);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(
|
|
104
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
105
|
+
buildRequestInit(options, systemPrompt, userPrompt, stream, controller.signal),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
response.status === 429 ||
|
|
112
|
+
response.status === 500 ||
|
|
113
|
+
response.status === 502 ||
|
|
114
|
+
response.status === 503
|
|
115
|
+
) {
|
|
116
|
+
lastError = new Error(
|
|
117
|
+
`OpenRouter ${response.status} (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const body = await response.text();
|
|
124
|
+
throw new NonRetriableError(`OpenRouter API error ${response.status}: ${body}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return response;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
clearTimeout(timeoutId);
|
|
130
|
+
|
|
131
|
+
if (error instanceof NonRetriableError) {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
135
|
+
throw new Error(`OpenRouter request timed out after ${options.timeout}s`);
|
|
136
|
+
}
|
|
137
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
138
|
+
if (attempt === MAX_RETRIES) break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw lastError ?? new Error("OpenRouter request failed after retries");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseUsage(
|
|
146
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number },
|
|
147
|
+
): LlmUsage {
|
|
148
|
+
return {
|
|
149
|
+
prompt_tokens: usage?.prompt_tokens ?? 0,
|
|
150
|
+
completion_tokens: usage?.completion_tokens ?? 0,
|
|
151
|
+
total_tokens: usage?.total_tokens ?? 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function readStream(
|
|
156
|
+
response: Response,
|
|
157
|
+
onChunk: StreamChunkCallback,
|
|
158
|
+
): Promise<LlmResponse> {
|
|
159
|
+
const reader = response.body!.getReader();
|
|
160
|
+
const decoder = new TextDecoder();
|
|
161
|
+
let accumulated = "";
|
|
162
|
+
let model = "";
|
|
163
|
+
let usage: LlmUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
164
|
+
let buffer = "";
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
while (true) {
|
|
168
|
+
const { done, value } = await reader.read();
|
|
169
|
+
if (done) break;
|
|
170
|
+
|
|
171
|
+
buffer += decoder.decode(value, { stream: true });
|
|
172
|
+
const lines = buffer.split("\n");
|
|
173
|
+
buffer = lines.pop() ?? "";
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const trimmed = line.trim();
|
|
177
|
+
if (!trimmed || trimmed === "data: [DONE]") continue;
|
|
178
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const chunk = JSON.parse(trimmed.slice(6)) as OpenRouterStreamChunk;
|
|
182
|
+
if (chunk.model) model = chunk.model;
|
|
183
|
+
if (chunk.usage) usage = parseUsage(chunk.usage);
|
|
184
|
+
|
|
185
|
+
const delta = chunk.choices[0]?.delta?.content;
|
|
186
|
+
if (delta) {
|
|
187
|
+
accumulated += delta;
|
|
188
|
+
onChunk(delta, accumulated);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// skip malformed SSE chunks
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
reader.releaseLock();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!accumulated) {
|
|
200
|
+
throw new NonRetriableError("OpenRouter returned empty streaming response");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { content: accumulated, model, usage };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createOpenRouterClient(options: LlmClientOptions): LlmClient {
|
|
207
|
+
return {
|
|
208
|
+
async complete(systemPrompt: string, userPrompt: string): Promise<LlmResponse> {
|
|
209
|
+
const response = await fetchWithRetry(options, systemPrompt, userPrompt, false);
|
|
210
|
+
const data = (await response.json()) as OpenRouterResponse;
|
|
211
|
+
const content = data.choices[0]?.message?.content;
|
|
212
|
+
if (!content) {
|
|
213
|
+
throw new NonRetriableError("OpenRouter returned empty response");
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
content,
|
|
217
|
+
model: data.model,
|
|
218
|
+
usage: parseUsage(data.usage),
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async completeStream(
|
|
223
|
+
systemPrompt: string,
|
|
224
|
+
userPrompt: string,
|
|
225
|
+
onChunk: StreamChunkCallback,
|
|
226
|
+
): Promise<LlmResponse> {
|
|
227
|
+
const response = await fetchWithRetry(options, systemPrompt, userPrompt, true);
|
|
228
|
+
return readStream(response, onChunk);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createLlmClient(options: LlmClientOptions): LlmClient {
|
|
234
|
+
if (options.api_key) {
|
|
235
|
+
return createOpenRouterClient(options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { createClaudeCodeClient: create } = require("./claude-code-client.ts");
|
|
239
|
+
return create(options.timeout);
|
|
240
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { DiffChunk } from "../types/diff.ts";
|
|
2
|
+
import type { FileGroup, FileStatus, PrSummary } from "../types/output.ts";
|
|
3
|
+
import type { PrCommit } from "../types/github.ts";
|
|
4
|
+
import type { ExplorationResult } from "../workspace/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface PromptPair {
|
|
7
|
+
system: string;
|
|
8
|
+
user: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FileSummaryInput {
|
|
12
|
+
path: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
status: FileStatus;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PromptContext {
|
|
18
|
+
commits?: PrCommit[];
|
|
19
|
+
language?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function langDirective(lang?: string): string {
|
|
23
|
+
if (!lang || lang === "English") return "";
|
|
24
|
+
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
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatCommitHistory(commits: PrCommit[]): string {
|
|
28
|
+
if (commits.length === 0) return "";
|
|
29
|
+
const lines = commits.map((c) => {
|
|
30
|
+
const firstLine = c.message.split("\n")[0]!;
|
|
31
|
+
const filesStr = c.files.length > 0 ? ` [${c.files.slice(0, 5).join(", ")}${c.files.length > 5 ? `, +${c.files.length - 5} more` : ""}]` : "";
|
|
32
|
+
return `- ${c.sha} ${firstLine}${filesStr}`;
|
|
33
|
+
});
|
|
34
|
+
return `\n\nCommit History (${commits.length} commits, chronological):\n${lines.join("\n")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildFileSummaryPrompt(chunks: DiffChunk[], ctx?: PromptContext): PromptPair {
|
|
38
|
+
const fileList = chunks
|
|
39
|
+
.map((c) => {
|
|
40
|
+
if (c.is_binary) return `File: ${c.file_path} (binary file, ${c.status})`;
|
|
41
|
+
return `File: ${c.file_path} (${c.status}, +${c.additions}/-${c.deletions})\n\`\`\`diff\n${c.diff_content}\n\`\`\``;
|
|
42
|
+
})
|
|
43
|
+
.join("\n\n---\n\n");
|
|
44
|
+
|
|
45
|
+
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
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.
|
|
50
|
+
Respond ONLY with a JSON array. Each element: {"path": "file/path", "summary": "one line description of what changed"}.
|
|
51
|
+
The "path" value must be the exact file path. The "summary" value is a human-readable description.
|
|
52
|
+
No markdown, no explanation, just the JSON array.${langDirective(ctx?.language)}`,
|
|
53
|
+
user: `${fileList}${commitCtx}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildGroupingPrompt(fileSummaries: FileSummaryInput[], ctx?: PromptContext): PromptPair {
|
|
58
|
+
const fileList = fileSummaries
|
|
59
|
+
.map((f) => `- ${f.path} (${f.status}): ${f.summary}`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
|
|
62
|
+
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
system: `You are an expert code reviewer. Group the following changed files by their semantic purpose.
|
|
66
|
+
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
|
+
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.
|
|
69
|
+
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
|
+
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
|
+
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}`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildOverallSummaryPrompt(
|
|
77
|
+
prTitle: string,
|
|
78
|
+
groups: FileGroup[],
|
|
79
|
+
fileSummaries: Array<{ path: string; summary: string }>,
|
|
80
|
+
ctx?: PromptContext,
|
|
81
|
+
): PromptPair {
|
|
82
|
+
const groupList = groups
|
|
83
|
+
.map((g) => `- [${g.type}] ${g.name}: ${g.description} (${g.files.length} files)`)
|
|
84
|
+
.join("\n");
|
|
85
|
+
|
|
86
|
+
const fileList = fileSummaries.map((f) => `- ${f.path}: ${f.summary}`).join("\n");
|
|
87
|
+
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
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.
|
|
92
|
+
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
|
+
The "purpose", "scope", and "impact" values are human-readable text. The "risk_level" must be one of: low, medium, high (in English).
|
|
94
|
+
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}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildNarrativePrompt(
|
|
100
|
+
prTitle: string,
|
|
101
|
+
summary: PrSummary,
|
|
102
|
+
groups: FileGroup[],
|
|
103
|
+
ctx?: PromptContext,
|
|
104
|
+
): PromptPair {
|
|
105
|
+
const groupDetails = groups
|
|
106
|
+
.map((g) => `### ${g.name} (${g.type})\n${g.description}\nFiles: ${g.files.join(", ")}`)
|
|
107
|
+
.join("\n\n");
|
|
108
|
+
|
|
109
|
+
const commitCtx = ctx?.commits ? formatCommitHistory(ctx.commits) : "";
|
|
110
|
+
const lang = ctx?.language && ctx.language !== "English" ? ctx.language : null;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
system: `You are an expert code reviewer writing a review walkthrough for other developers.
|
|
114
|
+
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.
|
|
116
|
+
Use markdown formatting. Write 2-5 paragraphs. Do NOT use JSON. Write natural prose.
|
|
117
|
+
${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
|
+
|
|
119
|
+
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
|
+
Use the EXACT group names and file paths provided. Every group MUST be referenced at least once. Reference key files where relevant.
|
|
121
|
+
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}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatCodebaseContext(exploration: ExplorationResult): string {
|
|
127
|
+
const sections: string[] = [];
|
|
128
|
+
if (exploration.project_structure) {
|
|
129
|
+
sections.push(`=== Project Structure ===\n${exploration.project_structure}`);
|
|
130
|
+
}
|
|
131
|
+
if (exploration.related_code) {
|
|
132
|
+
sections.push(`=== Related Code & Dependencies ===\n${exploration.related_code}`);
|
|
133
|
+
}
|
|
134
|
+
if (exploration.potential_issues) {
|
|
135
|
+
sections.push(`=== Potential Issues (from codebase analysis) ===\n${exploration.potential_issues}`);
|
|
136
|
+
}
|
|
137
|
+
return sections.join("\n\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildEnrichedSummaryPrompt(
|
|
141
|
+
prTitle: string,
|
|
142
|
+
groups: FileGroup[],
|
|
143
|
+
fileSummaries: Array<{ path: string; summary: string }>,
|
|
144
|
+
exploration: ExplorationResult,
|
|
145
|
+
ctx?: PromptContext,
|
|
146
|
+
): PromptPair {
|
|
147
|
+
const base = buildOverallSummaryPrompt(prTitle, groups, fileSummaries, ctx);
|
|
148
|
+
const context = formatCodebaseContext(exploration);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
system: base.system.replace(
|
|
152
|
+
"Provide an overall summary",
|
|
153
|
+
"Using the full codebase context below, provide a deeply informed summary",
|
|
154
|
+
),
|
|
155
|
+
user: `${base.user}\n\n--- CODEBASE CONTEXT (from agentic exploration) ---\n${context}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildEnrichedNarrativePrompt(
|
|
160
|
+
prTitle: string,
|
|
161
|
+
summary: PrSummary,
|
|
162
|
+
groups: FileGroup[],
|
|
163
|
+
exploration: ExplorationResult,
|
|
164
|
+
ctx?: PromptContext,
|
|
165
|
+
): PromptPair {
|
|
166
|
+
const base = buildNarrativePrompt(prTitle, summary, groups, ctx);
|
|
167
|
+
const context = formatCodebaseContext(exploration);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
system: `${base.system}
|
|
171
|
+
You have access to full codebase analysis. Use it to explain HOW the changes relate to existing code, not just WHAT changed.
|
|
172
|
+
Mention specific existing functions, modules, or patterns that are affected.
|
|
173
|
+
Remember to use [[group:Name]] and [[file:path]] tokens as instructed.`,
|
|
174
|
+
user: `${base.user}\n\n--- CODEBASE CONTEXT (from agentic exploration) ---\n${context}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FileGroup, GroupType, PrSummary, RiskLevel } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
function extractJson(raw: string): string {
|
|
4
|
+
const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
5
|
+
if (codeBlockMatch) return codeBlockMatch[1]!.trim();
|
|
6
|
+
return raw.trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const VALID_GROUP_TYPES = new Set<GroupType>([
|
|
10
|
+
"feature", "refactor", "bugfix", "chore", "docs", "test", "config",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const VALID_RISK_LEVELS = new Set<RiskLevel>(["low", "medium", "high"]);
|
|
14
|
+
|
|
15
|
+
export function parseFileSummaries(raw: string): Array<{ path: string; summary: string }> {
|
|
16
|
+
const jsonStr = extractJson(raw);
|
|
17
|
+
const parsed: unknown = JSON.parse(jsonStr);
|
|
18
|
+
|
|
19
|
+
if (!Array.isArray(parsed)) {
|
|
20
|
+
throw new Error("Expected JSON array for file summaries");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return parsed.map((item: Record<string, unknown>) => ({
|
|
24
|
+
path: String(item.path ?? ""),
|
|
25
|
+
summary: String(item.summary ?? "No summary available"),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseGroups(raw: string): FileGroup[] {
|
|
30
|
+
const jsonStr = extractJson(raw);
|
|
31
|
+
const parsed: unknown = JSON.parse(jsonStr);
|
|
32
|
+
|
|
33
|
+
if (!Array.isArray(parsed)) {
|
|
34
|
+
throw new Error("Expected JSON array for groups");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parsed.map((item: Record<string, unknown>) => {
|
|
38
|
+
const rawType = String(item.type ?? "chore");
|
|
39
|
+
const type: GroupType = VALID_GROUP_TYPES.has(rawType as GroupType)
|
|
40
|
+
? (rawType as GroupType)
|
|
41
|
+
: "chore";
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: String(item.name ?? "Ungrouped"),
|
|
45
|
+
type,
|
|
46
|
+
description: String(item.description ?? ""),
|
|
47
|
+
files: Array.isArray(item.files) ? item.files.map(String) : [],
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseSummary(raw: string): PrSummary {
|
|
53
|
+
const jsonStr = extractJson(raw);
|
|
54
|
+
const parsed = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
const rawRisk = String(parsed.risk_level ?? "medium");
|
|
57
|
+
const risk_level: RiskLevel = VALID_RISK_LEVELS.has(rawRisk as RiskLevel)
|
|
58
|
+
? (rawRisk as RiskLevel)
|
|
59
|
+
: "medium";
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
purpose: String(parsed.purpose ?? "No purpose provided"),
|
|
63
|
+
scope: String(parsed.scope ?? "Unknown scope"),
|
|
64
|
+
impact: String(parsed.impact ?? "Unknown impact"),
|
|
65
|
+
risk_level,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseNarrative(raw: string): string {
|
|
70
|
+
return raw.trim();
|
|
71
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { Box, useInput, useApp, useStdout } from "ink";
|
|
3
|
+
import type { NewprOutput } from "../types/output.ts";
|
|
4
|
+
import { Header } from "./Header.tsx";
|
|
5
|
+
import { TabBar } from "./TabBar.tsx";
|
|
6
|
+
import { Footer } from "./Footer.tsx";
|
|
7
|
+
import { HelpOverlay } from "./HelpOverlay.tsx";
|
|
8
|
+
import { StoryPanel } from "./panels/StoryPanel.tsx";
|
|
9
|
+
import { WalkthroughPanel } from "./panels/WalkthroughPanel.tsx";
|
|
10
|
+
import { SummaryPanel } from "./panels/SummaryPanel.tsx";
|
|
11
|
+
import { GroupsPanel } from "./panels/GroupsPanel.tsx";
|
|
12
|
+
import { FilesPanel } from "./panels/FilesPanel.tsx";
|
|
13
|
+
import { NarrativePanel } from "./panels/NarrativePanel.tsx";
|
|
14
|
+
|
|
15
|
+
const TAB_COUNT = 6;
|
|
16
|
+
|
|
17
|
+
export function App({ data, onBack }: { data: NewprOutput; onBack?: () => void }) {
|
|
18
|
+
const { exit } = useApp();
|
|
19
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
20
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
21
|
+
|
|
22
|
+
const switchTab = useCallback(
|
|
23
|
+
(dir: number) => setActiveTab((t) => (t + dir + TAB_COUNT) % TAB_COUNT),
|
|
24
|
+
[],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useInput(
|
|
28
|
+
(input, key) => {
|
|
29
|
+
if (input === "q") {
|
|
30
|
+
if (onBack) {
|
|
31
|
+
onBack();
|
|
32
|
+
} else {
|
|
33
|
+
exit();
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.escape && onBack) {
|
|
38
|
+
onBack();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (input === "?") {
|
|
42
|
+
setShowHelp((s) => !s);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (key.tab) {
|
|
46
|
+
switchTab(key.shift ? -1 : 1);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (input === "1") setActiveTab(0);
|
|
50
|
+
else if (input === "2") setActiveTab(1);
|
|
51
|
+
else if (input === "3") setActiveTab(2);
|
|
52
|
+
else if (input === "4") setActiveTab(3);
|
|
53
|
+
else if (input === "5") setActiveTab(4);
|
|
54
|
+
else if (input === "6") setActiveTab(5);
|
|
55
|
+
},
|
|
56
|
+
{ isActive: !showHelp },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (showHelp) {
|
|
60
|
+
return (
|
|
61
|
+
<Box flexDirection="column">
|
|
62
|
+
<Header meta={data.meta} />
|
|
63
|
+
<HelpOverlay onClose={() => setShowHelp(false)} />
|
|
64
|
+
</Box>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { stdout } = useStdout();
|
|
69
|
+
const termHeight = stdout?.rows ?? 24;
|
|
70
|
+
const panelHeight = Math.max(5, Math.floor(termHeight * 0.8) - 7);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Box flexDirection="column">
|
|
74
|
+
<Header meta={data.meta} />
|
|
75
|
+
<TabBar activeIndex={activeTab} />
|
|
76
|
+
|
|
77
|
+
<Box flexDirection="column" height={panelHeight} overflow="hidden">
|
|
78
|
+
{activeTab === 0 && (
|
|
79
|
+
<StoryPanel data={data} isFocused={activeTab === 0} viewportHeight={panelHeight} />
|
|
80
|
+
)}
|
|
81
|
+
{activeTab === 1 && (
|
|
82
|
+
<WalkthroughPanel data={data} isFocused={activeTab === 1} viewportHeight={panelHeight} />
|
|
83
|
+
)}
|
|
84
|
+
{activeTab === 2 && <SummaryPanel summary={data.summary} meta={data.meta} />}
|
|
85
|
+
{activeTab === 3 && (
|
|
86
|
+
<GroupsPanel groups={data.groups} files={data.files} isFocused={activeTab === 3} viewportHeight={panelHeight} />
|
|
87
|
+
)}
|
|
88
|
+
{activeTab === 4 && <FilesPanel files={data.files} isFocused={activeTab === 4} viewportHeight={panelHeight} />}
|
|
89
|
+
{activeTab === 5 && (
|
|
90
|
+
<NarrativePanel narrative={data.narrative} isFocused={activeTab === 5} viewportHeight={panelHeight} />
|
|
91
|
+
)}
|
|
92
|
+
</Box>
|
|
93
|
+
|
|
94
|
+
<Footer context={onBack ? "Esc/q: back" : undefined} />
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { T } from "./theme.ts";
|
|
3
|
+
|
|
4
|
+
function Key({ k }: { k: string }) {
|
|
5
|
+
return <Text color={T.primaryBold} bold>{k}</Text>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function Sep() {
|
|
9
|
+
return <Text color={T.faint}> │ </Text>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Footer({ context }: { context?: string }) {
|
|
13
|
+
return (
|
|
14
|
+
<Box paddingX={1} borderStyle="single" borderColor={T.border} borderTop borderBottom={false} borderLeft={false} borderRight={false}>
|
|
15
|
+
<Key k="Tab/1-6" /><Text dimColor> panels</Text>
|
|
16
|
+
<Sep />
|
|
17
|
+
<Key k="↑↓/jk" /><Text dimColor> scroll</Text>
|
|
18
|
+
<Sep />
|
|
19
|
+
<Key k="]/[" /><Text dimColor> anchor</Text>
|
|
20
|
+
<Sep />
|
|
21
|
+
<Key k="Enter" /><Text dimColor> pin</Text>
|
|
22
|
+
<Sep />
|
|
23
|
+
<Key k="?" /><Text dimColor> help</Text>
|
|
24
|
+
<Sep />
|
|
25
|
+
<Key k="q" /><Text dimColor> quit</Text>
|
|
26
|
+
{context && (
|
|
27
|
+
<>
|
|
28
|
+
<Sep />
|
|
29
|
+
<Text color={T.accent}>{context}</Text>
|
|
30
|
+
</>
|
|
31
|
+
)}
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import type { PrMeta } from "../types/output.ts";
|
|
3
|
+
import { T } from "./theme.ts";
|
|
4
|
+
|
|
5
|
+
export function Header({ meta }: { meta: PrMeta }) {
|
|
6
|
+
return (
|
|
7
|
+
<Box flexDirection="column" borderStyle="double" borderColor={T.primary} paddingX={1}>
|
|
8
|
+
<Box gap={1}>
|
|
9
|
+
<Text color={T.primary} bold>PR #{meta.pr_number}</Text>
|
|
10
|
+
<Text bold color={T.textBold}>{meta.pr_title}</Text>
|
|
11
|
+
</Box>
|
|
12
|
+
<Box gap={0}>
|
|
13
|
+
<Text color={T.muted}>{meta.author}</Text>
|
|
14
|
+
<Text dimColor> │ </Text>
|
|
15
|
+
<Text color={T.primary}>{meta.base_branch}</Text>
|
|
16
|
+
<Text color={T.faint}> ← </Text>
|
|
17
|
+
<Text color={T.primaryBold}>{meta.head_branch}</Text>
|
|
18
|
+
<Text dimColor> │ </Text>
|
|
19
|
+
<Text color={T.accent}>{meta.total_files_changed}</Text>
|
|
20
|
+
<Text dimColor> files │ </Text>
|
|
21
|
+
<Text color={T.added}>+{meta.total_additions}</Text>
|
|
22
|
+
<Text dimColor> </Text>
|
|
23
|
+
<Text color={T.deleted}>-{meta.total_deletions}</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
</Box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { T } from "./theme.ts";
|
|
3
|
+
|
|
4
|
+
const BINDINGS = [
|
|
5
|
+
["Tab / 1-6", "Switch panels"],
|
|
6
|
+
["↑ / k", "Move up / scroll up"],
|
|
7
|
+
["↓ / j", "Move down / scroll down"],
|
|
8
|
+
["] / [", "Next / prev anchor (Story)"],
|
|
9
|
+
["Enter", "Pin anchor / expand group"],
|
|
10
|
+
["← → / h l", "Prev / next step (Walk)"],
|
|
11
|
+
["/", "Filter files (Files panel)"],
|
|
12
|
+
["Esc", "Clear filter / close / back"],
|
|
13
|
+
["q", "Quit / go back"],
|
|
14
|
+
["?", "Toggle this help"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function HelpOverlay({ onClose }: { onClose: () => void }) {
|
|
18
|
+
useInput((input, key) => {
|
|
19
|
+
if (input === "?" || key.escape || input === "q") {
|
|
20
|
+
onClose();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Box
|
|
26
|
+
flexDirection="column"
|
|
27
|
+
borderStyle="round"
|
|
28
|
+
borderColor={T.primary}
|
|
29
|
+
paddingX={2}
|
|
30
|
+
paddingY={1}
|
|
31
|
+
>
|
|
32
|
+
<Text bold color={T.primary}> Keyboard Shortcuts </Text>
|
|
33
|
+
<Text> </Text>
|
|
34
|
+
{BINDINGS.map(([key, desc]) => (
|
|
35
|
+
<Box key={key} gap={1}>
|
|
36
|
+
<Box width={16}>
|
|
37
|
+
<Text bold color={T.primaryBold}>{key}</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
<Text color={T.muted}>{desc}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
))}
|
|
42
|
+
<Text> </Text>
|
|
43
|
+
<Text dimColor>Press <Text color={T.primaryBold}>?</Text> or <Text color={T.primaryBold}>Esc</Text> to close</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
);
|
|
46
|
+
}
|