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,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
|
+
}
|