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.
Files changed (82) hide show
  1. package/README.md +189 -0
  2. package/package.json +78 -0
  3. package/src/analyzer/errors.ts +22 -0
  4. package/src/analyzer/pipeline.ts +299 -0
  5. package/src/analyzer/progress.ts +69 -0
  6. package/src/cli/args.ts +192 -0
  7. package/src/cli/auth.ts +82 -0
  8. package/src/cli/history-cmd.ts +64 -0
  9. package/src/cli/index.ts +115 -0
  10. package/src/cli/pretty.ts +79 -0
  11. package/src/config/index.ts +103 -0
  12. package/src/config/store.ts +50 -0
  13. package/src/diff/chunker.ts +30 -0
  14. package/src/diff/parser.ts +116 -0
  15. package/src/diff/stats.ts +37 -0
  16. package/src/github/auth.ts +16 -0
  17. package/src/github/fetch-diff.ts +24 -0
  18. package/src/github/fetch-pr.ts +90 -0
  19. package/src/github/parse-pr.ts +39 -0
  20. package/src/history/store.ts +96 -0
  21. package/src/history/types.ts +15 -0
  22. package/src/llm/claude-code-client.ts +134 -0
  23. package/src/llm/client.ts +240 -0
  24. package/src/llm/prompts.ts +176 -0
  25. package/src/llm/response-parser.ts +71 -0
  26. package/src/tui/App.tsx +97 -0
  27. package/src/tui/Footer.tsx +34 -0
  28. package/src/tui/Header.tsx +27 -0
  29. package/src/tui/HelpOverlay.tsx +46 -0
  30. package/src/tui/InputBar.tsx +65 -0
  31. package/src/tui/Loading.tsx +192 -0
  32. package/src/tui/Shell.tsx +384 -0
  33. package/src/tui/TabBar.tsx +31 -0
  34. package/src/tui/commands.ts +75 -0
  35. package/src/tui/narrative-parser.ts +143 -0
  36. package/src/tui/panels/FilesPanel.tsx +134 -0
  37. package/src/tui/panels/GroupsPanel.tsx +140 -0
  38. package/src/tui/panels/NarrativePanel.tsx +102 -0
  39. package/src/tui/panels/StoryPanel.tsx +296 -0
  40. package/src/tui/panels/SummaryPanel.tsx +59 -0
  41. package/src/tui/panels/WalkthroughPanel.tsx +149 -0
  42. package/src/tui/render.tsx +62 -0
  43. package/src/tui/theme.ts +44 -0
  44. package/src/types/config.ts +19 -0
  45. package/src/types/diff.ts +36 -0
  46. package/src/types/github.ts +28 -0
  47. package/src/types/output.ts +59 -0
  48. package/src/web/client/App.tsx +121 -0
  49. package/src/web/client/components/AppShell.tsx +203 -0
  50. package/src/web/client/components/DetailPane.tsx +141 -0
  51. package/src/web/client/components/ErrorScreen.tsx +119 -0
  52. package/src/web/client/components/InputScreen.tsx +41 -0
  53. package/src/web/client/components/LoadingTimeline.tsx +179 -0
  54. package/src/web/client/components/Markdown.tsx +109 -0
  55. package/src/web/client/components/ResizeHandle.tsx +45 -0
  56. package/src/web/client/components/ResultsScreen.tsx +185 -0
  57. package/src/web/client/components/SettingsPanel.tsx +299 -0
  58. package/src/web/client/hooks/useAnalysis.ts +153 -0
  59. package/src/web/client/hooks/useGithubUser.ts +24 -0
  60. package/src/web/client/hooks/useSessions.ts +17 -0
  61. package/src/web/client/hooks/useTheme.ts +34 -0
  62. package/src/web/client/main.tsx +12 -0
  63. package/src/web/client/panels/FilesPanel.tsx +85 -0
  64. package/src/web/client/panels/GroupsPanel.tsx +62 -0
  65. package/src/web/client/panels/NarrativePanel.tsx +9 -0
  66. package/src/web/client/panels/StoryPanel.tsx +54 -0
  67. package/src/web/client/panels/SummaryPanel.tsx +20 -0
  68. package/src/web/components/ui/button.tsx +46 -0
  69. package/src/web/components/ui/card.tsx +37 -0
  70. package/src/web/components/ui/scroll-area.tsx +39 -0
  71. package/src/web/components/ui/tabs.tsx +52 -0
  72. package/src/web/index.html +14 -0
  73. package/src/web/lib/utils.ts +6 -0
  74. package/src/web/server/routes.ts +202 -0
  75. package/src/web/server/session-manager.ts +147 -0
  76. package/src/web/server.ts +96 -0
  77. package/src/web/styles/globals.css +91 -0
  78. package/src/workspace/agent.ts +317 -0
  79. package/src/workspace/explore.ts +82 -0
  80. package/src/workspace/repo-cache.ts +69 -0
  81. package/src/workspace/types.ts +30 -0
  82. package/src/workspace/worktree.ts +129 -0
@@ -0,0 +1,299 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { X, Check, Loader2, Key, Bot, Globe, Settings2 } from "lucide-react";
3
+ import { Button } from "../../components/ui/button.tsx";
4
+
5
+ interface ConfigData {
6
+ model: string;
7
+ agent: string | null;
8
+ language: string;
9
+ max_files: number;
10
+ timeout: number;
11
+ concurrency: number;
12
+ has_api_key: boolean;
13
+ has_github_token: boolean;
14
+ defaults: {
15
+ model: string;
16
+ language: string;
17
+ max_files: number;
18
+ timeout: number;
19
+ concurrency: number;
20
+ };
21
+ }
22
+
23
+ const MODELS = [
24
+ "anthropic/claude-sonnet-4.5",
25
+ "anthropic/claude-sonnet-4-20250514",
26
+ "openai/gpt-4.1",
27
+ "openai/o3",
28
+ "google/gemini-2.5-pro-preview-06-05",
29
+ ];
30
+
31
+ const AGENTS = [
32
+ { value: "", label: "Auto" },
33
+ { value: "claude", label: "Claude Code" },
34
+ { value: "opencode", label: "OpenCode" },
35
+ { value: "codex", label: "Codex" },
36
+ ];
37
+
38
+ const LANGUAGES = [
39
+ "auto", "English", "Korean", "Japanese", "Chinese",
40
+ "Spanish", "French", "German", "Portuguese",
41
+ ];
42
+
43
+ export function SettingsPanel({ onClose }: { onClose: () => void }) {
44
+ const [config, setConfig] = useState<ConfigData | null>(null);
45
+ const [saving, setSaving] = useState(false);
46
+ const [saved, setSaved] = useState(false);
47
+ const [apiKeyInput, setApiKeyInput] = useState("");
48
+ const [showApiKeyField, setShowApiKeyField] = useState(false);
49
+
50
+ useEffect(() => {
51
+ fetch("/api/config")
52
+ .then((r) => r.json())
53
+ .then((data) => setConfig(data as ConfigData))
54
+ .catch(() => {});
55
+ }, []);
56
+
57
+ const save = useCallback(async (update: Record<string, unknown>) => {
58
+ setSaving(true);
59
+ setSaved(false);
60
+ try {
61
+ await fetch("/api/config", {
62
+ method: "PUT",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify(update),
65
+ });
66
+ const res = await fetch("/api/config");
67
+ const data = await res.json();
68
+ setConfig(data as ConfigData);
69
+ setSaved(true);
70
+ setTimeout(() => setSaved(false), 2000);
71
+ } finally {
72
+ setSaving(false);
73
+ }
74
+ }, []);
75
+
76
+ if (!config) {
77
+ return (
78
+ <div className="flex items-center justify-center py-20">
79
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
80
+ </div>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <div className="flex flex-col gap-0">
86
+ <div className="flex items-center justify-between pb-6">
87
+ <h2 className="text-lg font-semibold tracking-tight">Settings</h2>
88
+ <div className="flex items-center gap-2">
89
+ {saving && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
90
+ {saved && <Check className="h-3.5 w-3.5 text-green-500" />}
91
+ <button
92
+ type="button"
93
+ onClick={onClose}
94
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
95
+ >
96
+ <X className="h-4 w-4" />
97
+ </button>
98
+ </div>
99
+ </div>
100
+
101
+ <div className="space-y-8">
102
+ <Section icon={Key} title="Authentication">
103
+ <Row label="OpenRouter API Key">
104
+ {showApiKeyField ? (
105
+ <div className="flex gap-2">
106
+ <input
107
+ type="password"
108
+ value={apiKeyInput}
109
+ onChange={(e) => setApiKeyInput(e.target.value)}
110
+ placeholder="sk-or-..."
111
+ className="flex-1 h-8 rounded-md border bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
112
+ autoFocus
113
+ />
114
+ <Button
115
+ size="sm"
116
+ disabled={!apiKeyInput.trim()}
117
+ onClick={() => {
118
+ save({ openrouter_api_key: apiKeyInput.trim() });
119
+ setApiKeyInput("");
120
+ setShowApiKeyField(false);
121
+ }}
122
+ >
123
+ Save
124
+ </Button>
125
+ <Button
126
+ variant="ghost"
127
+ size="sm"
128
+ onClick={() => { setShowApiKeyField(false); setApiKeyInput(""); }}
129
+ >
130
+ Cancel
131
+ </Button>
132
+ </div>
133
+ ) : (
134
+ <div className="flex items-center gap-3">
135
+ <StatusDot ok={config.has_api_key} />
136
+ <span className="text-sm text-muted-foreground">
137
+ {config.has_api_key ? "Configured" : "Not set"}
138
+ </span>
139
+ <button
140
+ type="button"
141
+ onClick={() => setShowApiKeyField(true)}
142
+ className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
143
+ >
144
+ {config.has_api_key ? "Change" : "Set key"}
145
+ </button>
146
+ </div>
147
+ )}
148
+ </Row>
149
+ <Row label="GitHub Token">
150
+ <div className="flex items-center gap-3">
151
+ <StatusDot ok={config.has_github_token} />
152
+ <span className="text-sm text-muted-foreground">
153
+ {config.has_github_token ? "Detected from gh CLI" : "Not detected"}
154
+ </span>
155
+ </div>
156
+ </Row>
157
+ </Section>
158
+
159
+ <Section icon={Bot} title="Model & Agent">
160
+ <Row label="Model">
161
+ <select
162
+ value={config.model}
163
+ onChange={(e) => save({ model: e.target.value })}
164
+ className="h-8 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
165
+ >
166
+ {MODELS.map((m) => (
167
+ <option key={m} value={m}>{m.split("/").pop()}</option>
168
+ ))}
169
+ </select>
170
+ </Row>
171
+ <Row label="Exploration Agent">
172
+ <div className="flex gap-1.5">
173
+ {AGENTS.map((a) => (
174
+ <button
175
+ key={a.value}
176
+ type="button"
177
+ onClick={() => save({ agent: a.value })}
178
+ className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
179
+ (config.agent ?? "") === a.value
180
+ ? "bg-primary text-primary-foreground"
181
+ : "bg-muted text-muted-foreground hover:text-foreground"
182
+ }`}
183
+ >
184
+ {a.label}
185
+ </button>
186
+ ))}
187
+ </div>
188
+ </Row>
189
+ </Section>
190
+
191
+ <Section icon={Globe} title="Language">
192
+ <Row label="Output Language">
193
+ <select
194
+ value={config.language}
195
+ onChange={(e) => save({ language: e.target.value })}
196
+ className="h-8 rounded-md border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring cursor-pointer"
197
+ >
198
+ {LANGUAGES.map((l) => (
199
+ <option key={l} value={l}>{l === "auto" ? "Auto-detect" : l}</option>
200
+ ))}
201
+ </select>
202
+ </Row>
203
+ </Section>
204
+
205
+ <Section icon={Settings2} title="Advanced">
206
+ <Row label="Max files">
207
+ <NumberInput
208
+ value={config.max_files}
209
+ defaultValue={config.defaults.max_files}
210
+ onChange={(v) => save({ max_files: v })}
211
+ />
212
+ </Row>
213
+ <Row label="Timeout (sec)">
214
+ <NumberInput
215
+ value={config.timeout}
216
+ defaultValue={config.defaults.timeout}
217
+ onChange={(v) => save({ timeout: v })}
218
+ />
219
+ </Row>
220
+ <Row label="Concurrency">
221
+ <NumberInput
222
+ value={config.concurrency}
223
+ defaultValue={config.defaults.concurrency}
224
+ onChange={(v) => save({ concurrency: v })}
225
+ />
226
+ </Row>
227
+ </Section>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ function Section({
234
+ icon: Icon,
235
+ title,
236
+ children,
237
+ }: {
238
+ icon: typeof Key;
239
+ title: string;
240
+ children: React.ReactNode;
241
+ }) {
242
+ return (
243
+ <div>
244
+ <div className="flex items-center gap-2 mb-4">
245
+ <Icon className="h-4 w-4 text-muted-foreground" />
246
+ <h3 className="text-sm font-medium">{title}</h3>
247
+ </div>
248
+ <div className="space-y-4 pl-6">{children}</div>
249
+ </div>
250
+ );
251
+ }
252
+
253
+ function Row({ label, children }: { label: string; children: React.ReactNode }) {
254
+ return (
255
+ <div className="flex items-center justify-between gap-4">
256
+ <label className="text-sm text-muted-foreground shrink-0">{label}</label>
257
+ <div className="flex-1 flex justify-end">{children}</div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ function StatusDot({ ok }: { ok: boolean }) {
263
+ return (
264
+ <span className={`h-2 w-2 rounded-full ${ok ? "bg-green-500" : "bg-red-500"}`} />
265
+ );
266
+ }
267
+
268
+ function NumberInput({
269
+ value,
270
+ onChange,
271
+ }: {
272
+ value: number;
273
+ defaultValue?: number;
274
+ onChange: (v: number) => void;
275
+ }) {
276
+ const [local, setLocal] = useState(String(value));
277
+
278
+ useEffect(() => { setLocal(String(value)); }, [value]);
279
+
280
+ function handleBlur() {
281
+ const parsed = Number.parseInt(local, 10);
282
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed !== value) {
283
+ onChange(parsed);
284
+ } else {
285
+ setLocal(String(value));
286
+ }
287
+ }
288
+
289
+ return (
290
+ <input
291
+ type="number"
292
+ value={local}
293
+ onChange={(e) => setLocal(e.target.value)}
294
+ onBlur={handleBlur}
295
+ onKeyDown={(e) => { if (e.key === "Enter") handleBlur(); }}
296
+ className="w-20 h-8 rounded-md border bg-background px-3 text-sm text-right font-mono focus:outline-none focus:ring-1 focus:ring-ring"
297
+ />
298
+ );
299
+ }
@@ -0,0 +1,153 @@
1
+ import { useState, useCallback, useRef } from "react";
2
+ import type { ProgressEvent } from "../../../analyzer/progress.ts";
3
+ import type { NewprOutput } from "../../../types/output.ts";
4
+
5
+ type Phase = "idle" | "loading" | "done" | "error";
6
+
7
+ interface AnalysisState {
8
+ phase: Phase;
9
+ sessionId: string | null;
10
+ events: ProgressEvent[];
11
+ result: NewprOutput | null;
12
+ error: string | null;
13
+ startedAt: number | null;
14
+ lastPrInput: string | null;
15
+ }
16
+
17
+ export function useAnalysis() {
18
+ const [state, setState] = useState<AnalysisState>({
19
+ phase: "idle",
20
+ sessionId: null,
21
+ events: [],
22
+ result: null,
23
+ error: null,
24
+ startedAt: null,
25
+ lastPrInput: null,
26
+ });
27
+ const eventSourceRef = useRef<EventSource | null>(null);
28
+
29
+ const start = useCallback(async (prInput: string) => {
30
+ setState({
31
+ phase: "loading",
32
+ sessionId: null,
33
+ events: [],
34
+ result: null,
35
+ error: null,
36
+ startedAt: Date.now(),
37
+ lastPrInput: prInput,
38
+ });
39
+
40
+ try {
41
+ const res = await fetch("/api/analysis", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ pr: prInput }),
45
+ });
46
+ const body = await res.json();
47
+ if (!res.ok) throw new Error(body.error ?? "Failed to start analysis");
48
+
49
+ const { sessionId, eventsUrl } = body as { sessionId: string; eventsUrl: string };
50
+ setState((s) => ({ ...s, sessionId }));
51
+
52
+ const es = new EventSource(eventsUrl);
53
+ eventSourceRef.current = es;
54
+
55
+ es.addEventListener("progress", (e) => {
56
+ const event = JSON.parse(e.data) as ProgressEvent;
57
+ setState((s) => {
58
+ const events = [...s.events];
59
+ const lastIdx = events.length - 1;
60
+ if (
61
+ lastIdx >= 0 &&
62
+ events[lastIdx]!.stage === event.stage &&
63
+ event.partial_content &&
64
+ events[lastIdx]!.partial_content
65
+ ) {
66
+ events[lastIdx] = event;
67
+ } else {
68
+ events.push(event);
69
+ }
70
+ return { ...s, events };
71
+ });
72
+ });
73
+
74
+ es.addEventListener("done", async () => {
75
+ es.close();
76
+ eventSourceRef.current = null;
77
+ const resultRes = await fetch(`/api/analysis/${sessionId}`);
78
+ const data = await resultRes.json();
79
+ setState((s) => ({
80
+ ...s,
81
+ phase: "done",
82
+ result: data.result ?? null,
83
+ }));
84
+ });
85
+
86
+ es.addEventListener("analysis_error", (e) => {
87
+ es.close();
88
+ eventSourceRef.current = null;
89
+ let msg = "Analysis failed";
90
+ try { msg = JSON.parse((e as MessageEvent).data).message ?? msg; } catch {}
91
+ setState((s) => ({ ...s, phase: "error", error: msg }));
92
+ });
93
+
94
+ es.onerror = () => {
95
+ if (es.readyState === EventSource.CLOSED) {
96
+ eventSourceRef.current = null;
97
+ }
98
+ };
99
+ } catch (err) {
100
+ setState((s) => ({
101
+ ...s,
102
+ phase: "error",
103
+ error: err instanceof Error ? err.message : String(err),
104
+ }));
105
+ }
106
+ }, []);
107
+
108
+ const loadStoredSession = useCallback(async (sessionId: string) => {
109
+ setState((s) => ({
110
+ ...s,
111
+ phase: "loading",
112
+ events: [],
113
+ result: null,
114
+ error: null,
115
+ startedAt: Date.now(),
116
+ lastPrInput: null,
117
+ }));
118
+
119
+ try {
120
+ const res = await fetch(`/api/sessions/${sessionId}`);
121
+ if (!res.ok) throw new Error("Session not found");
122
+ const data = await res.json() as NewprOutput;
123
+ setState((s) => ({
124
+ ...s,
125
+ phase: "done",
126
+ result: data,
127
+ sessionId,
128
+ }));
129
+ } catch (err) {
130
+ setState((s) => ({
131
+ ...s,
132
+ phase: "error",
133
+ error: err instanceof Error ? err.message : String(err),
134
+ }));
135
+ }
136
+ }, []);
137
+
138
+ const reset = useCallback(() => {
139
+ eventSourceRef.current?.close();
140
+ eventSourceRef.current = null;
141
+ setState({
142
+ phase: "idle",
143
+ sessionId: null,
144
+ events: [],
145
+ result: null,
146
+ error: null,
147
+ startedAt: null,
148
+ lastPrInput: null,
149
+ });
150
+ }, []);
151
+
152
+ return { ...state, start, loadStoredSession, reset };
153
+ }
@@ -0,0 +1,24 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ export interface GithubUser {
4
+ login: string;
5
+ avatar_url: string;
6
+ html_url: string;
7
+ name: string | null;
8
+ }
9
+
10
+ export function useGithubUser() {
11
+ const [user, setUser] = useState<GithubUser | null>(null);
12
+
13
+ useEffect(() => {
14
+ fetch("/api/me")
15
+ .then((r) => r.json())
16
+ .then((data) => {
17
+ const d = data as Record<string, unknown>;
18
+ if (d.login) setUser(d as unknown as GithubUser);
19
+ })
20
+ .catch(() => {});
21
+ }, []);
22
+
23
+ return user;
24
+ }
@@ -0,0 +1,17 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import type { SessionRecord } from "../../../history/types.ts";
3
+
4
+ export function useSessions() {
5
+ const [sessions, setSessions] = useState<SessionRecord[]>([]);
6
+
7
+ const refresh = useCallback(() => {
8
+ fetch("/api/sessions")
9
+ .then((r) => r.json())
10
+ .then((data) => setSessions(data as SessionRecord[]))
11
+ .catch(() => {});
12
+ }, []);
13
+
14
+ useEffect(() => { refresh(); }, [refresh]);
15
+
16
+ return { sessions, refresh };
17
+ }
@@ -0,0 +1,34 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ type Theme = "light" | "dark" | "system";
4
+
5
+ function getSystemTheme(): "light" | "dark" {
6
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
7
+ }
8
+
9
+ function applyTheme(theme: Theme) {
10
+ const resolved = theme === "system" ? getSystemTheme() : theme;
11
+ document.documentElement.classList.toggle("dark", resolved === "dark");
12
+ }
13
+
14
+ export function useTheme() {
15
+ const [theme, setThemeState] = useState<Theme>(() => {
16
+ const stored = localStorage.getItem("newpr-theme");
17
+ return (stored as Theme) ?? "system";
18
+ });
19
+
20
+ useEffect(() => {
21
+ applyTheme(theme);
22
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
23
+ const handler = () => { if (theme === "system") applyTheme("system"); };
24
+ mq.addEventListener("change", handler);
25
+ return () => mq.removeEventListener("change", handler);
26
+ }, [theme]);
27
+
28
+ const setTheme = useCallback((t: Theme) => {
29
+ localStorage.setItem("newpr-theme", t);
30
+ setThemeState(t);
31
+ }, []);
32
+
33
+ return { theme, setTheme };
34
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App.tsx";
4
+
5
+ const el = document.getElementById("root");
6
+ if (el) {
7
+ createRoot(el).render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>,
11
+ );
12
+ }
@@ -0,0 +1,85 @@
1
+ import { useState } from "react";
2
+ import { ChevronDown, ChevronRight, Plus, Pencil, Trash2, ArrowRight } from "lucide-react";
3
+ import type { FileChange, FileStatus } from "../../../types/output.ts";
4
+
5
+ const STATUS_ICON: Record<FileStatus, typeof Plus> = {
6
+ added: Plus,
7
+ modified: Pencil,
8
+ deleted: Trash2,
9
+ renamed: ArrowRight,
10
+ };
11
+
12
+ const STATUS_COLOR: Record<FileStatus, string> = {
13
+ added: "text-green-500",
14
+ modified: "text-yellow-500",
15
+ deleted: "text-red-500",
16
+ renamed: "text-blue-500",
17
+ };
18
+
19
+ function splitPath(fullPath: string): { dir: string; name: string } {
20
+ const lastSlash = fullPath.lastIndexOf("/");
21
+ if (lastSlash === -1) return { dir: "", name: fullPath };
22
+ return { dir: fullPath.slice(0, lastSlash + 1), name: fullPath.slice(lastSlash + 1) };
23
+ }
24
+
25
+ export function FilesPanel({ files }: { files: FileChange[] }) {
26
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
27
+
28
+ function toggle(path: string) {
29
+ setExpanded((s) => {
30
+ const next = new Set(s);
31
+ next.has(path) ? next.delete(path) : next.add(path);
32
+ return next;
33
+ });
34
+ }
35
+
36
+ return (
37
+ <div className="pt-6">
38
+ <div className="text-xs text-muted-foreground mb-3">
39
+ {files.length} files changed
40
+ </div>
41
+ <div className="divide-y">
42
+ {files.map((file) => {
43
+ const Icon = STATUS_ICON[file.status];
44
+ const open = expanded.has(file.path);
45
+ const { dir, name } = splitPath(file.path);
46
+
47
+ return (
48
+ <div key={file.path}>
49
+ <button
50
+ type="button"
51
+ onClick={() => toggle(file.path)}
52
+ className="w-full flex items-center gap-3 py-2.5 text-left hover:bg-accent/30 transition-colors min-w-0 -mx-1 px-1 rounded"
53
+ >
54
+ {open ? (
55
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
56
+ ) : (
57
+ <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
58
+ )}
59
+ <Icon className={`h-3.5 w-3.5 shrink-0 ${STATUS_COLOR[file.status]}`} />
60
+ <span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
61
+ <span className="text-xs text-muted-foreground/50 font-mono truncate shrink">{dir}</span>
62
+ <span className="text-sm font-mono font-medium shrink-0">{name}</span>
63
+ </span>
64
+ <span className="text-xs tabular-nums text-green-500 shrink-0 w-10 text-right">+{file.additions}</span>
65
+ <span className="text-xs tabular-nums text-red-500 shrink-0 w-10 text-right">−{file.deletions}</span>
66
+ </button>
67
+ {open && (
68
+ <div className="pb-3 pl-12">
69
+ <p className="text-xs text-muted-foreground leading-relaxed break-words">{file.summary}</p>
70
+ {file.groups.length > 0 && (
71
+ <div className="flex flex-wrap gap-1.5 mt-2">
72
+ {file.groups.map((g) => (
73
+ <span key={g} className="text-[11px] bg-muted px-2 py-0.5 rounded-full">{g}</span>
74
+ ))}
75
+ </div>
76
+ )}
77
+ </div>
78
+ )}
79
+ </div>
80
+ );
81
+ })}
82
+ </div>
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,62 @@
1
+ import { useState } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import type { FileGroup } from "../../../types/output.ts";
4
+
5
+ const TYPE_COLORS: Record<string, string> = {
6
+ feature: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
7
+ refactor: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
8
+ bugfix: "bg-red-500/10 text-red-600 dark:text-red-400",
9
+ chore: "bg-gray-500/10 text-gray-600 dark:text-gray-400",
10
+ docs: "bg-teal-500/10 text-teal-600 dark:text-teal-400",
11
+ test: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
12
+ config: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
13
+ };
14
+
15
+ export function GroupsPanel({ groups }: { groups: FileGroup[] }) {
16
+ const [expanded, setExpanded] = useState<Set<number>>(new Set([0]));
17
+
18
+ function toggle(idx: number) {
19
+ setExpanded((s) => {
20
+ const next = new Set(s);
21
+ next.has(idx) ? next.delete(idx) : next.add(idx);
22
+ return next;
23
+ });
24
+ }
25
+
26
+ return (
27
+ <div className="pt-6 divide-y">
28
+ {groups.map((group, i) => (
29
+ <div key={group.name}>
30
+ <button
31
+ type="button"
32
+ className="w-full flex items-center gap-3 min-w-0 py-4 text-left hover:bg-accent/30 transition-colors -mx-2 px-2 rounded-md"
33
+ onClick={() => toggle(i)}
34
+ >
35
+ {expanded.has(i) ? (
36
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
37
+ ) : (
38
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
39
+ )}
40
+ <span className="text-sm font-medium flex-1 min-w-0 truncate">{group.name}</span>
41
+ <span className={`text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ${TYPE_COLORS[group.type] ?? TYPE_COLORS.chore}`}>
42
+ {group.type}
43
+ </span>
44
+ <span className="text-xs text-muted-foreground shrink-0">{group.files.length} files</span>
45
+ </button>
46
+ {expanded.has(i) && (
47
+ <div className="pb-4 pl-8">
48
+ <p className="text-sm text-muted-foreground mb-3 break-words">{group.description}</p>
49
+ <div className="space-y-1">
50
+ {group.files.map((f) => (
51
+ <div key={f} className="text-xs font-mono text-muted-foreground pl-2 border-l-2 border-border truncate" title={f}>
52
+ {f}
53
+ </div>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ )}
58
+ </div>
59
+ ))}
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,9 @@
1
+ import { Markdown } from "../components/Markdown.tsx";
2
+
3
+ export function NarrativePanel({ narrative }: { narrative: string }) {
4
+ return (
5
+ <div className="pt-6">
6
+ <Markdown>{narrative}</Markdown>
7
+ </div>
8
+ );
9
+ }