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,65 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { T } from "./theme.ts";
4
+
5
+ export function InputBar({
6
+ placeholder,
7
+ onSubmit,
8
+ onChange,
9
+ initialValue,
10
+ }: {
11
+ placeholder: string;
12
+ onSubmit: (value: string) => void;
13
+ onChange?: (value: string) => void;
14
+ initialValue?: string;
15
+ }) {
16
+ const [value, setValue] = useState(initialValue ?? "");
17
+ const [focused] = useState(true);
18
+
19
+ useEffect(() => {
20
+ if (initialValue !== undefined) setValue(initialValue);
21
+ }, [initialValue]);
22
+
23
+ useEffect(() => {
24
+ onChange?.(value);
25
+ }, [value]);
26
+
27
+ useInput(
28
+ (input, key) => {
29
+ if (key.return) {
30
+ if (value.trim()) {
31
+ onSubmit(value.trim());
32
+ setValue("");
33
+ }
34
+ return;
35
+ }
36
+ if (key.backspace || key.delete) {
37
+ setValue((v) => v.slice(0, -1));
38
+ return;
39
+ }
40
+ if (key.ctrl && input === "u") {
41
+ setValue("");
42
+ return;
43
+ }
44
+ if (key.escape) {
45
+ setValue("");
46
+ return;
47
+ }
48
+ if (input && !key.ctrl && !key.meta && !key.escape) {
49
+ setValue((v) => v + input);
50
+ }
51
+ },
52
+ { isActive: focused },
53
+ );
54
+
55
+ return (
56
+ <Box paddingX={2}>
57
+ <Text color={T.primary} bold>{"❯ "}</Text>
58
+ {value ? (
59
+ <Text color={T.text}>{value}<Text color={T.primary}>█</Text></Text>
60
+ ) : (
61
+ <Text color={T.faint}>{placeholder}<Text color={T.primary}>█</Text></Text>
62
+ )}
63
+ </Box>
64
+ );
65
+ }
@@ -0,0 +1,192 @@
1
+ import { Box, Text } from "ink";
2
+ import Spinner from "ink-spinner";
3
+ import { stageIndex, allStages, type ProgressStage, type ProgressEvent } from "../analyzer/progress.ts";
4
+ import { T } from "./theme.ts";
5
+
6
+ const STAGE_LABELS: Record<ProgressStage, string> = {
7
+ fetching: "Fetch PR data",
8
+ cloning: "Clone repository",
9
+ checkout: "Checkout branches",
10
+ exploring: "Explore codebase",
11
+ parsing: "Parse diff",
12
+ analyzing: "Analyze files",
13
+ grouping: "Group changes",
14
+ summarizing: "Generate summary",
15
+ narrating: "Write narrative",
16
+ done: "Complete",
17
+ };
18
+
19
+ const STREAM_PREVIEW_LINES = 4;
20
+ const STREAM_LINE_MAX_CHARS = 80;
21
+
22
+ export interface StepLog {
23
+ stage: ProgressStage;
24
+ message: string;
25
+ current?: number;
26
+ total?: number;
27
+ done: boolean;
28
+ partial_content?: string;
29
+ durationMs?: number;
30
+ }
31
+
32
+ function getPreviewLines(content: string): string {
33
+ const raw = content
34
+ .replace(/^[\s\n]*\{?\s*/, "")
35
+ .replace(/["{}[\]]/g, "")
36
+ .replace(/\\n/g, "\n")
37
+ .replace(/^\s*\w+\s*:\s*/gm, "");
38
+
39
+ const lines = raw
40
+ .split("\n")
41
+ .map((l) => l.trim())
42
+ .filter(Boolean);
43
+
44
+ return lines
45
+ .slice(-STREAM_PREVIEW_LINES)
46
+ .map((l) => (l.length > STREAM_LINE_MAX_CHARS ? l.slice(0, STREAM_LINE_MAX_CHARS) + "…" : l))
47
+ .join("\n");
48
+ }
49
+
50
+ export function buildStepLog(events: ProgressEvent[]): StepLog[] {
51
+ const stages = allStages();
52
+ const lastEventByStage = new Map<ProgressStage, ProgressEvent>();
53
+ const firstTimestamp = new Map<ProgressStage, number>();
54
+ let maxStageIdx = -1;
55
+
56
+ for (const e of events) {
57
+ lastEventByStage.set(e.stage, e);
58
+ if (e.timestamp && !firstTimestamp.has(e.stage)) {
59
+ firstTimestamp.set(e.stage, e.timestamp);
60
+ }
61
+ const idx = stageIndex(e.stage);
62
+ if (idx > maxStageIdx) maxStageIdx = idx;
63
+ }
64
+
65
+ return stages
66
+ .filter((_, i) => i <= maxStageIdx + 1 && i < stages.length - 1)
67
+ .map((stage) => {
68
+ const event = lastEventByStage.get(stage);
69
+ const idx = stageIndex(stage);
70
+ const done = idx < maxStageIdx;
71
+
72
+ let durationMs: number | undefined;
73
+ if (done) {
74
+ const start = firstTimestamp.get(stage);
75
+ const nextStages = stages.filter((_, i) => i > idx);
76
+ const nextStart = nextStages.reduce<number | undefined>(
77
+ (found, s) => found ?? firstTimestamp.get(s),
78
+ undefined,
79
+ );
80
+ if (start && nextStart) durationMs = nextStart - start;
81
+ }
82
+
83
+ return {
84
+ stage,
85
+ message: event?.message ?? STAGE_LABELS[stage],
86
+ current: event?.current,
87
+ total: event?.total,
88
+ done,
89
+ partial_content: event?.partial_content,
90
+ durationMs,
91
+ };
92
+ });
93
+ }
94
+
95
+ export function LoadingTimeline({ steps, elapsed }: { steps: StepLog[]; elapsed: number }) {
96
+ return (
97
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
98
+ <Box gap={1} marginBottom={1}>
99
+ <Text bold color={T.primary}>newpr</Text>
100
+ <Text color={T.faint}>│</Text>
101
+ <Text color={T.muted}>{formatElapsed(elapsed)}</Text>
102
+ </Box>
103
+
104
+ {steps.map((step, i) => {
105
+ const isLast = i === steps.length - 1;
106
+ const isActive = isLast && !step.done;
107
+ const progress =
108
+ step.current !== undefined && step.total !== undefined
109
+ ? ` (${step.current}/${step.total})`
110
+ : "";
111
+ const preview = isActive && step.partial_content
112
+ ? getPreviewLines(step.partial_content)
113
+ : null;
114
+ const duration = step.done && step.durationMs !== undefined
115
+ ? formatDuration(step.durationMs)
116
+ : null;
117
+ const detail = step.message !== STAGE_LABELS[step.stage]
118
+ ? step.message
119
+ : "";
120
+
121
+ return (
122
+ <Box key={step.stage} flexDirection="column">
123
+ <Box gap={1}>
124
+ {step.done ? (
125
+ <Text color={T.ok}>✓</Text>
126
+ ) : isActive ? (
127
+ <Text color={T.primary}><Spinner type="dots" /></Text>
128
+ ) : (
129
+ <Text color={T.faint}>○</Text>
130
+ )}
131
+ <Text color={step.done ? T.muted : isActive ? T.primary : T.faint} bold={isActive}>
132
+ {STAGE_LABELS[step.stage]}
133
+ </Text>
134
+ {step.done && (
135
+ <Text color={T.faint}>
136
+ {detail ? `${detail} ` : ""}{duration && `(${duration})`}
137
+ </Text>
138
+ )}
139
+ {isActive && (
140
+ <Text color={T.muted}>
141
+ {detail}
142
+ {progress}
143
+ </Text>
144
+ )}
145
+ </Box>
146
+ {preview && (
147
+ <Box marginLeft={3} marginBottom={0}>
148
+ <Text color={T.faint} dimColor>
149
+ {preview}
150
+ </Text>
151
+ </Box>
152
+ )}
153
+ </Box>
154
+ );
155
+ })}
156
+ </Box>
157
+ );
158
+ }
159
+
160
+ function formatElapsed(ms: number): string {
161
+ const s = Math.floor(ms / 1000);
162
+ if (s < 60) return `${s}s`;
163
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
164
+ }
165
+
166
+ function formatDuration(ms: number): string {
167
+ if (ms < 1000) return `${ms}ms`;
168
+ const s = (ms / 1000).toFixed(1);
169
+ return `${s}s`;
170
+ }
171
+
172
+ export function Loading({
173
+ message,
174
+ current,
175
+ total,
176
+ }: { message: string; current?: number; total?: number }) {
177
+ const progress = current !== undefined && total !== undefined ? ` (${current}/${total})` : "";
178
+
179
+ return (
180
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
181
+ <Box gap={1}>
182
+ <Text color={T.primary}>
183
+ <Spinner type="dots" />
184
+ </Text>
185
+ <Text>
186
+ {message}
187
+ {progress}
188
+ </Text>
189
+ </Box>
190
+ </Box>
191
+ );
192
+ }
@@ -0,0 +1,384 @@
1
+ import { useState, useCallback, useEffect, useRef } from "react";
2
+ import { Box, Text, useInput, useApp } from "ink";
3
+ import type { NewprOutput } from "../types/output.ts";
4
+ import type { NewprConfig } from "../types/config.ts";
5
+ import type { ProgressEvent } from "../analyzer/progress.ts";
6
+ import type { SessionRecord } from "../history/types.ts";
7
+ import type { AgentToolName } from "../workspace/types.ts";
8
+ import { parsePrInput } from "../github/parse-pr.ts";
9
+ import { analyzePr } from "../analyzer/pipeline.ts";
10
+ import { saveSession, listSessions, loadSession } from "../history/store.ts";
11
+ import { detectAgents } from "../workspace/agent.ts";
12
+ import { App } from "./App.tsx";
13
+ import { InputBar } from "./InputBar.tsx";
14
+ import { LoadingTimeline, buildStepLog, type StepLog } from "./Loading.tsx";
15
+ import { T, RISK_COLORS } from "./theme.ts";
16
+ import { filterCommands, executeCommand, type CmdResult } from "./commands.ts";
17
+
18
+ type ShellState =
19
+ | { phase: "idle" }
20
+ | { phase: "loading"; steps: StepLog[]; startTime: number }
21
+ | { phase: "results"; data: NewprOutput }
22
+ | { phase: "error"; message: string };
23
+
24
+ interface ShellProps {
25
+ token: string;
26
+ config: NewprConfig;
27
+ initialPr?: string;
28
+ }
29
+
30
+ const VERSION = "0.1.0";
31
+
32
+ const LOGO = ` ████████ ██████ █████ ███ █████ ████████ ████████
33
+ ░░███░░███ ███░░███░░███ ░███░░███ ░░███░░███░░███░░███
34
+ ░███ ░███ ░███████ ░███ ░███ ░███ ░███ ░███ ░███ ░░░
35
+ ░███ ░███ ░███░░░ ░░███████████ ░███ ░███ ░███
36
+ ████ █████░░██████ ░░████░████ ░███████ █████
37
+ ░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░ ░███░░░ ░░░░░
38
+ ░███
39
+ █████
40
+ ░░░░░░`;
41
+
42
+ export function Shell({ token, config: initialConfig, initialPr }: ShellProps) {
43
+ const { exit } = useApp();
44
+ const [liveConfig, setLiveConfig] = useState<NewprConfig>(initialConfig);
45
+ const [state, setState] = useState<ShellState>({ phase: "idle" });
46
+ const [sessions, setSessions] = useState<SessionRecord[]>([]);
47
+ const [elapsed, setElapsed] = useState(0);
48
+ const [detectedAgent, setDetectedAgent] = useState<AgentToolName | null>(null);
49
+ const autoStarted = useRef(false);
50
+ const eventsRef = useRef<ProgressEvent[]>([]);
51
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
52
+
53
+ useEffect(() => {
54
+ listSessions(10).then(setSessions);
55
+ }, []);
56
+
57
+ useEffect(() => {
58
+ detectAgents().then((agents) => {
59
+ if (liveConfig.agent) {
60
+ const found = agents.find((a) => a.name === liveConfig.agent);
61
+ setDetectedAgent(found ? found.name : null);
62
+ } else if (agents.length > 0) {
63
+ setDetectedAgent(agents[0]!.name);
64
+ }
65
+ });
66
+ }, [liveConfig.agent]);
67
+
68
+ useEffect(() => {
69
+ if (state.phase === "loading") {
70
+ timerRef.current = setInterval(() => {
71
+ setElapsed(Date.now() - state.startTime);
72
+ }, 500);
73
+ return () => {
74
+ if (timerRef.current) clearInterval(timerRef.current);
75
+ };
76
+ }
77
+ if (timerRef.current) clearInterval(timerRef.current);
78
+ }, [state.phase, state.phase === "loading" ? state.startTime : 0]);
79
+
80
+ const handleCommand = useCallback(async (input: string): Promise<CmdResult> => {
81
+ const result = await executeCommand(input, liveConfig);
82
+ if (result.configUpdate) {
83
+ setLiveConfig((prev) => ({ ...prev, ...result.configUpdate }));
84
+ }
85
+ return result;
86
+ }, [liveConfig]);
87
+
88
+ const analyze = useCallback(
89
+ async (input: string) => {
90
+ try {
91
+ const pr = parsePrInput(input.trim());
92
+ const startTime = Date.now();
93
+ eventsRef.current = [];
94
+ setState({ phase: "loading", steps: [], startTime });
95
+ setElapsed(0);
96
+
97
+ const result = await analyzePr({
98
+ pr,
99
+ token,
100
+ config: liveConfig,
101
+ onProgress: (event: ProgressEvent) => {
102
+ const stamped = { ...event, timestamp: event.timestamp ?? Date.now() };
103
+ const prev = eventsRef.current;
104
+ const lastIdx = prev.length - 1;
105
+ if (
106
+ lastIdx >= 0 &&
107
+ prev[lastIdx]!.stage === stamped.stage &&
108
+ stamped.partial_content &&
109
+ prev[lastIdx]!.partial_content
110
+ ) {
111
+ eventsRef.current = [...prev.slice(0, lastIdx), stamped];
112
+ } else {
113
+ eventsRef.current = [...prev, stamped];
114
+ }
115
+ const steps = buildStepLog(eventsRef.current);
116
+ setState({ phase: "loading", steps, startTime });
117
+ },
118
+ });
119
+
120
+ await saveSession(result);
121
+ const updated = await listSessions(10);
122
+ setSessions(updated);
123
+
124
+ setState({ phase: "results", data: result });
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ setState({ phase: "error", message: msg });
128
+ }
129
+ },
130
+ [token, liveConfig],
131
+ );
132
+
133
+ const loadFromHistory = useCallback(async (sessionId: string) => {
134
+ eventsRef.current = [];
135
+ setState({ phase: "loading", steps: [], startTime: Date.now() });
136
+ const data = await loadSession(sessionId);
137
+ if (data) {
138
+ setState({ phase: "results", data });
139
+ } else {
140
+ setState({ phase: "error", message: "Session data not found." });
141
+ }
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ if (initialPr && !autoStarted.current) {
146
+ autoStarted.current = true;
147
+ analyze(initialPr);
148
+ }
149
+ }, [initialPr, analyze]);
150
+
151
+ const goBack = useCallback(() => {
152
+ setState({ phase: "idle" });
153
+ }, []);
154
+
155
+ if (state.phase === "idle" || state.phase === "error") {
156
+ return (
157
+ <IdleScreen
158
+ error={state.phase === "error" ? state.message : undefined}
159
+ sessions={sessions}
160
+ config={liveConfig}
161
+ detectedAgent={detectedAgent}
162
+ onCommand={handleCommand}
163
+ onSubmit={analyze}
164
+ onLoadSession={loadFromHistory}
165
+ onQuit={exit}
166
+ />
167
+ );
168
+ }
169
+
170
+ if (state.phase === "loading") {
171
+ return <LoadingTimeline steps={state.steps} elapsed={elapsed} />;
172
+ }
173
+
174
+ return <App data={state.data} onBack={goBack} />;
175
+ }
176
+
177
+ function IdleScreen({
178
+ error,
179
+ sessions,
180
+ config,
181
+ detectedAgent,
182
+ onCommand,
183
+ onSubmit,
184
+ onLoadSession,
185
+ onQuit,
186
+ }: {
187
+ error?: string;
188
+ sessions: SessionRecord[];
189
+ config: NewprConfig;
190
+ detectedAgent: AgentToolName | null;
191
+ onCommand: (input: string) => Promise<CmdResult>;
192
+ onSubmit: (input: string) => void;
193
+ onLoadSession: (id: string) => void;
194
+ onQuit: () => void;
195
+ }) {
196
+ const [mode, setMode] = useState<"input" | "history">(sessions.length > 0 ? "history" : "input");
197
+ const [historyIdx, setHistoryIdx] = useState(0);
198
+ const [inputValue, setInputValue] = useState("");
199
+ const [notice, setNotice] = useState<CmdResult | null>(null);
200
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
201
+
202
+ function showNotice(result: CmdResult) {
203
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
204
+ setNotice(result);
205
+ noticeTimer.current = setTimeout(() => setNotice(null), 4000);
206
+ }
207
+
208
+ function handleSubmit(value: string) {
209
+ if (value.startsWith("/")) {
210
+ onCommand(value).then(showNotice);
211
+ } else {
212
+ setNotice(null);
213
+ onSubmit(value);
214
+ }
215
+ }
216
+
217
+ useInput(
218
+ (input, key) => {
219
+ if (input === "q") {
220
+ onQuit();
221
+ return;
222
+ }
223
+
224
+ if (mode === "history") {
225
+ if (key.upArrow || input === "k") {
226
+ setHistoryIdx((i) => Math.max(0, i - 1));
227
+ return;
228
+ }
229
+ if (key.downArrow || input === "j") {
230
+ setHistoryIdx((i) => Math.min(sessions.length - 1, i + 1));
231
+ return;
232
+ }
233
+ if (key.return) {
234
+ const s = sessions[historyIdx];
235
+ if (s) onLoadSession(s.id);
236
+ return;
237
+ }
238
+ if (input === "n" || input === "/") {
239
+ setMode("input");
240
+ return;
241
+ }
242
+ }
243
+
244
+ if (mode === "input") {
245
+ if (key.escape && sessions.length > 0) {
246
+ setMode("history");
247
+ return;
248
+ }
249
+ }
250
+ },
251
+ { isActive: mode === "history" },
252
+ );
253
+
254
+ const showPalette = mode === "input" && inputValue.startsWith("/");
255
+ const paletteItems = showPalette ? filterCommands(inputValue) : [];
256
+
257
+ return (
258
+ <Box flexDirection="column">
259
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
260
+ <Text bold color={T.primary}>{LOGO}</Text>
261
+ <Box gap={1} paddingLeft={2}>
262
+ <Text color={T.muted}>v{VERSION}</Text>
263
+ <Text color={T.faint}>│</Text>
264
+ <Text color={T.muted}>{config.model.split("/").pop()}</Text>
265
+ <Text color={T.faint}>│</Text>
266
+ <Text color={T.muted}>{config.language}</Text>
267
+ <Text color={T.faint}>│</Text>
268
+ {detectedAgent
269
+ ? <Text color={T.primary}>{detectedAgent}</Text>
270
+ : <Text color={T.error}>no agent</Text>
271
+ }
272
+ </Box>
273
+ </Box>
274
+
275
+ {notice && (
276
+ <Box paddingX={2} marginBottom={1}>
277
+ <Text color={notice.ok ? T.ok : T.error} bold>{notice.ok ? "✓" : "✗"} </Text>
278
+ <Text color={notice.ok ? T.text : T.error}>{notice.text}</Text>
279
+ </Box>
280
+ )}
281
+
282
+ {error && !notice && (
283
+ <Box paddingX={2} marginBottom={1}>
284
+ <Text color={T.error} bold>✗ </Text>
285
+ <Text color={T.error}>{error}</Text>
286
+ </Box>
287
+ )}
288
+
289
+ {sessions.length > 0 && (
290
+ <Box flexDirection="column" paddingX={2} marginBottom={1}>
291
+ <Box gap={1} marginBottom={1}>
292
+ <Text color={mode === "history" ? T.primary : T.muted} bold>
293
+ Recent Sessions
294
+ </Text>
295
+ <Text color={T.faint}>│</Text>
296
+ <Text color={T.muted}>
297
+ <Text color={T.primaryBold}>Enter</Text> open <Text color={T.primaryBold}>n</Text> new
298
+ </Text>
299
+ </Box>
300
+ {sessions.slice(0, 5).map((s, i) => {
301
+ const isSelected = mode === "history" && i === historyIdx;
302
+ const riskColor = RISK_COLORS[s.risk_level] ?? T.warn;
303
+ const ago = formatTimeAgo(s.analyzed_at);
304
+ return (
305
+ <Box key={s.id} gap={1}>
306
+ <Text inverse={isSelected} bold={isSelected}>
307
+ {isSelected ? "❯" : " "}
308
+ <Text color={isSelected ? undefined : T.primary}> #{s.pr_number}</Text>
309
+ {" "}
310
+ <Text color={isSelected ? undefined : T.text}>{s.pr_title.slice(0, 50)}</Text>
311
+ </Text>
312
+ {!isSelected && (
313
+ <>
314
+ <Text color={riskColor}>●</Text>
315
+ <Text color={T.muted}>{s.repo}</Text>
316
+ <Text color={T.faint}>{ago}</Text>
317
+ </>
318
+ )}
319
+ </Box>
320
+ );
321
+ })}
322
+ </Box>
323
+ )}
324
+
325
+ {mode === "input" && (
326
+ <Box flexDirection="column">
327
+ <InputBar
328
+ placeholder="PR URL, owner/repo#123, or / for commands..."
329
+ onSubmit={handleSubmit}
330
+ onChange={setInputValue}
331
+ />
332
+ {showPalette && (
333
+ <Box flexDirection="column" paddingX={4} marginTop={0}>
334
+ {paletteItems.map((cmd) => (
335
+ <Box key={cmd.name} gap={1}>
336
+ <Text color={T.primary}>/{cmd.name}</Text>
337
+ {cmd.args && <Text color={T.faint}>{cmd.args}</Text>}
338
+ <Text color={T.muted}>{cmd.desc}</Text>
339
+ </Box>
340
+ ))}
341
+ {paletteItems.length === 0 && (
342
+ <Text color={T.faint}>No matching commands</Text>
343
+ )}
344
+ </Box>
345
+ )}
346
+ </Box>
347
+ )}
348
+
349
+ {mode === "history" && (
350
+ <Box paddingX={2}>
351
+ <Text color={T.faint}>Press </Text>
352
+ <Text color={T.primaryBold} bold>n</Text>
353
+ <Text color={T.faint}> or </Text>
354
+ <Text color={T.primaryBold} bold>/</Text>
355
+ <Text color={T.faint}> to analyze a new PR</Text>
356
+ </Box>
357
+ )}
358
+
359
+ <Box paddingX={2} marginTop={1}>
360
+ <Text color={T.primaryBold} bold>Enter</Text><Text color={T.muted}> {mode === "history" ? "open session" : "analyze"} </Text>
361
+ {sessions.length > 0 && (
362
+ <>
363
+ <Text color={T.primaryBold} bold>{mode === "history" ? "n" : "Esc"}</Text>
364
+ <Text color={T.muted}> {mode === "history" ? "new PR" : "history"} </Text>
365
+ </>
366
+ )}
367
+ <Text color={T.primaryBold} bold>/</Text><Text color={T.muted}> commands </Text>
368
+ <Text color={T.primaryBold} bold>q</Text><Text color={T.muted}> quit</Text>
369
+ </Box>
370
+ </Box>
371
+ );
372
+ }
373
+
374
+ function formatTimeAgo(isoDate: string): string {
375
+ const diff = Date.now() - new Date(isoDate).getTime();
376
+ const minutes = Math.floor(diff / 60000);
377
+ if (minutes < 1) return "just now";
378
+ if (minutes < 60) return `${minutes}m ago`;
379
+ const hours = Math.floor(minutes / 60);
380
+ if (hours < 24) return `${hours}h ago`;
381
+ const days = Math.floor(hours / 24);
382
+ if (days < 30) return `${days}d ago`;
383
+ return `${Math.floor(days / 30)}mo ago`;
384
+ }
@@ -0,0 +1,31 @@
1
+ import { Box, Text } from "ink";
2
+ import { T } from "./theme.ts";
3
+
4
+ const TABS = [
5
+ { label: "Story", icon: "▶" },
6
+ { label: "Walk", icon: "⟫" },
7
+ { label: "Summary", icon: "◈" },
8
+ { label: "Groups", icon: "◆" },
9
+ { label: "Files", icon: "◇" },
10
+ { label: "Narrative", icon: "¶" },
11
+ ];
12
+
13
+ export function TabBar({ activeIndex }: { activeIndex: number }) {
14
+ return (
15
+ <Box paddingX={1} gap={1}>
16
+ {TABS.map((tab, i) => {
17
+ const active = i === activeIndex;
18
+ return (
19
+ <Text
20
+ key={tab.label}
21
+ bold={active}
22
+ inverse={active}
23
+ color={active ? T.primary : T.muted}
24
+ >
25
+ {` ${tab.icon} ${i + 1}:${tab.label} `}
26
+ </Text>
27
+ );
28
+ })}
29
+ </Box>
30
+ );
31
+ }