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,296 @@
1
+ import React, { useState, useMemo, useCallback } from "react";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import type { NewprOutput, FileGroup, FileChange } from "../../types/output.ts";
4
+ import {
5
+ parseNarrativeAnchors,
6
+ type NarrativeAnchor,
7
+ } from "../narrative-parser.ts";
8
+ import { T, STATUS_STYLE, TYPE_STYLE } from "../theme.ts";
9
+
10
+ interface DetailTarget {
11
+ kind: "group" | "file";
12
+ group?: FileGroup;
13
+ files: FileChange[];
14
+ file?: FileChange;
15
+ }
16
+
17
+ function resolveDetail(
18
+ anchor: NarrativeAnchor,
19
+ groups: FileGroup[],
20
+ files: FileChange[],
21
+ ): DetailTarget | null {
22
+ if (anchor.kind === "group") {
23
+ const group = groups.find((g) => g.name === anchor.id);
24
+ if (!group) return null;
25
+ const groupFiles = files.filter((f) => group.files.includes(f.path));
26
+ return { kind: "group", group, files: groupFiles };
27
+ }
28
+ const file = files.find((f) => f.path === anchor.id);
29
+ if (!file) return null;
30
+ return { kind: "file", file, files: [file] };
31
+ }
32
+
33
+ function renderNarrativeLine(
34
+ line: string,
35
+ lineIndex: number,
36
+ anchors: NarrativeAnchor[],
37
+ activeAnchorIdx: number,
38
+ allAnchors: NarrativeAnchor[],
39
+ ): React.ReactNode {
40
+ const lineAnchors = anchors.filter((a) => a.lineIndex === lineIndex);
41
+ if (lineAnchors.length === 0) {
42
+ return renderPlainLine(line);
43
+ }
44
+
45
+ const parts: React.ReactNode[] = [];
46
+ let cursor = 0;
47
+ let key = 0;
48
+
49
+ for (const anchor of lineAnchors) {
50
+ if (anchor.startCol > cursor) {
51
+ parts.push(
52
+ <Text key={key++} color={T.text}>
53
+ {line.slice(cursor, anchor.startCol)}
54
+ </Text>,
55
+ );
56
+ }
57
+
58
+ const globalIdx = allAnchors.indexOf(anchor);
59
+ const isActive = globalIdx === activeAnchorIdx;
60
+ const label = line.slice(anchor.startCol, anchor.endCol);
61
+
62
+ parts.push(
63
+ <Text key={key++} inverse={isActive} bold={isActive} color={isActive ? T.textBold : T.primaryBold} underline={!isActive}>
64
+ {label}
65
+ </Text>,
66
+ );
67
+
68
+ cursor = anchor.endCol;
69
+ }
70
+
71
+ if (cursor < line.length) {
72
+ parts.push(<Text key={key++} color={T.text}>{line.slice(cursor)}</Text>);
73
+ }
74
+
75
+ return <Text>{...parts}</Text>;
76
+ }
77
+
78
+ function renderPlainLine(line: string): React.ReactNode {
79
+ if (line.startsWith("### ")) return <Text bold color={T.primaryBold}>{line.slice(4)}</Text>;
80
+ if (line.startsWith("## ")) return <Text bold color={T.primary} underline>{line.slice(3)}</Text>;
81
+ if (line.startsWith("# ")) return <Text bold color={T.primary} underline>{line.slice(2)}</Text>;
82
+ if (line.startsWith("- ") || line.startsWith("* ")) {
83
+ return <Text><Text color={T.primary}> •</Text> {line.slice(2)}</Text>;
84
+ }
85
+ return <Text color={T.text}>{line}</Text>;
86
+ }
87
+
88
+ function DetailPane({
89
+ target,
90
+ detailScroll,
91
+ height,
92
+ }: { target: DetailTarget | null; detailScroll: number; height: number }) {
93
+ if (!target) {
94
+ return (
95
+ <Box flexDirection="column" paddingX={1} paddingY={1}>
96
+ <Text color={T.faint} italic>Navigate to an anchor</Text>
97
+ <Text color={T.faint} italic>to see details here</Text>
98
+ <Text> </Text>
99
+ <Text color={T.faint}>]/[ next/prev anchor</Text>
100
+ <Text color={T.faint}>Enter to pin</Text>
101
+ </Box>
102
+ );
103
+ }
104
+
105
+ if (target.kind === "group" && target.group) {
106
+ const g = target.group;
107
+ const typeColor = TYPE_STYLE[g.type]?.color ?? T.muted;
108
+ const lines: React.ReactNode[] = [
109
+ <Box key="name" gap={1}>
110
+ <Text bold color={typeColor}>{g.name}</Text>
111
+ <Text color={T.muted}>({g.type})</Text>
112
+ </Box>,
113
+ <Text key="desc" color={T.muted} wrap="wrap">{g.description}</Text>,
114
+ <Text key="sep" color={T.faint}>{"─".repeat(28)}</Text>,
115
+ ];
116
+
117
+ for (const f of target.files) {
118
+ const s = STATUS_STYLE[f.status] ?? { icon: "?", color: T.muted };
119
+ lines.push(
120
+ <Box key={`f-${f.path}`} flexDirection="column">
121
+ <Box gap={1}>
122
+ <Text color={s.color} bold>{s.icon}</Text>
123
+ <Text color={T.text}>{f.path.split("/").pop()}</Text>
124
+ <Text color={T.added}>+{f.additions}</Text>
125
+ <Text color={T.deleted}>-{f.deletions}</Text>
126
+ </Box>
127
+ <Text color={T.muted} wrap="wrap"> {f.summary}</Text>
128
+ </Box>,
129
+ );
130
+ }
131
+
132
+ const visible = lines.slice(detailScroll, detailScroll + height - 1);
133
+ return <Box flexDirection="column" paddingX={1}>{...visible}</Box>;
134
+ }
135
+
136
+ if (target.kind === "file" && target.file) {
137
+ const f = target.file;
138
+ const s = STATUS_STYLE[f.status] ?? { icon: "?", color: T.muted };
139
+ return (
140
+ <Box flexDirection="column" paddingX={1}>
141
+ <Box gap={1}>
142
+ <Text color={s.color} bold>{s.icon}</Text>
143
+ <Text bold color={T.text}>{f.path}</Text>
144
+ </Box>
145
+ <Box gap={1}>
146
+ <Text color={T.added}>+{f.additions}</Text>
147
+ <Text color={T.deleted}>-{f.deletions}</Text>
148
+ <Text color={T.faint}>[{f.groups.join(", ")}]</Text>
149
+ </Box>
150
+ <Text color={T.faint}>{"─".repeat(28)}</Text>
151
+ <Text color={T.muted} wrap="wrap">{f.summary}</Text>
152
+ </Box>
153
+ );
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ export function StoryPanel({
160
+ data,
161
+ isFocused,
162
+ viewportHeight,
163
+ }: { data: NewprOutput; isFocused: boolean; viewportHeight: number }) {
164
+ const { stdout } = useStdout();
165
+ const termWidth = stdout?.columns ?? 80;
166
+ const leftWidth = Math.max(30, Math.floor(termWidth * 0.62));
167
+ const rightWidth = Math.max(20, termWidth - leftWidth - 4);
168
+
169
+ const parsed = useMemo(() => parseNarrativeAnchors(data.narrative), [data.narrative]);
170
+
171
+ const [scrollOffset, setScrollOffset] = useState(0);
172
+ const [activeAnchorIdx, setActiveAnchorIdx] = useState(0);
173
+ const [pinned, setPinned] = useState(false);
174
+ const [focusPane, setFocusPane] = useState<"narrative" | "detail">("narrative");
175
+ const [detailScroll, setDetailScroll] = useState(0);
176
+
177
+ const { displayLines, allAnchors } = parsed;
178
+ const maxScroll = Math.max(0, displayLines.length - viewportHeight + 3);
179
+
180
+ const currentAnchor = allAnchors[activeAnchorIdx] ?? null;
181
+ const detailTarget = useMemo(
182
+ () => currentAnchor ? resolveDetail(currentAnchor, data.groups, data.files) : null,
183
+ [currentAnchor, data.groups, data.files],
184
+ );
185
+
186
+ const jumpToAnchor = useCallback((idx: number) => {
187
+ const anchor = allAnchors[idx];
188
+ if (!anchor) return;
189
+ setActiveAnchorIdx(idx);
190
+ setDetailScroll(0);
191
+ if (anchor.lineIndex < scrollOffset || anchor.lineIndex >= scrollOffset + viewportHeight - 3) {
192
+ setScrollOffset(Math.max(0, Math.min(anchor.lineIndex - 2, maxScroll)));
193
+ }
194
+ }, [allAnchors, scrollOffset, viewportHeight, maxScroll]);
195
+
196
+ useInput(
197
+ (input, key) => {
198
+ if (key.tab) {
199
+ setFocusPane((p) => p === "narrative" ? "detail" : "narrative");
200
+ return;
201
+ }
202
+
203
+ if (focusPane === "narrative") {
204
+ let explicitNav = false;
205
+
206
+ if (key.upArrow || input === "k") {
207
+ setScrollOffset((s) => Math.max(0, s - 1));
208
+ } else if (key.downArrow || input === "j") {
209
+ setScrollOffset((s) => Math.min(maxScroll, s + 1));
210
+ } else if (input === "]") {
211
+ explicitNav = true;
212
+ const next = Math.min(allAnchors.length - 1, activeAnchorIdx + 1);
213
+ jumpToAnchor(next);
214
+ } else if (input === "[") {
215
+ explicitNav = true;
216
+ const prev = Math.max(0, activeAnchorIdx - 1);
217
+ jumpToAnchor(prev);
218
+ } else if (key.return) {
219
+ setPinned((p) => !p);
220
+ }
221
+
222
+ if (!pinned && !explicitNav && !key.return && allAnchors.length > 0) {
223
+ const visibleStart = scrollOffset;
224
+ const visibleEnd = scrollOffset + viewportHeight - 3;
225
+ const midLine = Math.floor((visibleStart + visibleEnd) / 2);
226
+ const closest = allAnchors.reduce((best, a, i) => {
227
+ const dist = Math.abs(a.lineIndex - midLine);
228
+ const bestDist = best.idx === -1 ? Infinity : Math.abs(allAnchors[best.idx]!.lineIndex - midLine);
229
+ return dist < bestDist ? { idx: i, dist } : best;
230
+ }, { idx: -1, dist: Infinity });
231
+ if (closest.idx >= 0 && closest.idx !== activeAnchorIdx) {
232
+ setActiveAnchorIdx(closest.idx);
233
+ setDetailScroll(0);
234
+ }
235
+ }
236
+ } else {
237
+ if (key.upArrow || input === "k") {
238
+ setDetailScroll((s) => Math.max(0, s - 1));
239
+ } else if (key.downArrow || input === "j") {
240
+ setDetailScroll((s) => s + 1);
241
+ } else if (key.escape) {
242
+ setFocusPane("narrative");
243
+ }
244
+ }
245
+ },
246
+ { isActive: isFocused },
247
+ );
248
+
249
+ const visibleCount = Math.max(1, viewportHeight - 3);
250
+ const visible = displayLines.slice(scrollOffset, scrollOffset + visibleCount);
251
+ const canScrollUp = scrollOffset > 0;
252
+ const canScrollDown = scrollOffset < maxScroll;
253
+
254
+ const anchorInfo = allAnchors.length > 0
255
+ ? `${activeAnchorIdx + 1}/${allAnchors.length}`
256
+ : "no anchors";
257
+
258
+ return (
259
+ <Box flexDirection="column">
260
+ <Box gap={1} paddingX={1}>
261
+ <Text bold color={T.primary}>Story</Text>
262
+ <Text color={T.faint}>│</Text>
263
+ <Text color={T.muted}>
264
+ Anchor {anchorInfo}
265
+ {pinned ? <Text color={T.accent}> [pinned]</Text> : ""}
266
+ </Text>
267
+ <Text color={T.faint}>│</Text>
268
+ <Text color={focusPane === "narrative" ? T.primary : T.muted} bold={focusPane === "narrative"}>
269
+ Narrative
270
+ </Text>
271
+ <Text color={focusPane === "detail" ? T.primary : T.muted} bold={focusPane === "detail"}>
272
+ Detail
273
+ </Text>
274
+ </Box>
275
+
276
+ <Box>
277
+ <Box flexDirection="column" width={leftWidth} borderStyle="single" borderColor={focusPane === "narrative" ? T.primary : T.border} borderRight borderLeft={false} borderTop={false} borderBottom={false}>
278
+ {canScrollUp && <Text color={T.faint}> ↑</Text>}
279
+ {visible.map((line, vi) => {
280
+ const lineIdx = scrollOffset + vi;
281
+ return (
282
+ <Box key={lineIdx} paddingX={1}>
283
+ {renderNarrativeLine(line, lineIdx, allAnchors, activeAnchorIdx, allAnchors)}
284
+ </Box>
285
+ );
286
+ })}
287
+ {canScrollDown && <Text color={T.faint}> ↓</Text>}
288
+ </Box>
289
+
290
+ <Box flexDirection="column" width={rightWidth}>
291
+ <DetailPane target={detailTarget} detailScroll={detailScroll} height={viewportHeight - 2} />
292
+ </Box>
293
+ </Box>
294
+ </Box>
295
+ );
296
+ }
@@ -0,0 +1,59 @@
1
+ import { Box, Text } from "ink";
2
+ import type { PrSummary, PrMeta } from "../../types/output.ts";
3
+ import { T, RISK_COLORS } from "../theme.ts";
4
+
5
+ const RISK_ICONS: Record<string, string> = {
6
+ low: "●",
7
+ medium: "◐",
8
+ high: "◉",
9
+ };
10
+
11
+ function Field({ label, value }: { label: string; value: string }) {
12
+ return (
13
+ <Box gap={1}>
14
+ <Box width={10}>
15
+ <Text bold color={T.primary}>{label}:</Text>
16
+ </Box>
17
+ <Text color={T.text} wrap="wrap">{value}</Text>
18
+ </Box>
19
+ );
20
+ }
21
+
22
+ export function SummaryPanel({ summary, meta }: { summary: PrSummary; meta: PrMeta }) {
23
+ const riskColor = RISK_COLORS[summary.risk_level] ?? T.warn;
24
+ const riskIcon = RISK_ICONS[summary.risk_level] ?? "●";
25
+
26
+ return (
27
+ <Box flexDirection="column" paddingX={2} paddingY={1} gap={1}>
28
+ <Text bold color={T.primary}>◈ PR Summary</Text>
29
+
30
+ <Box flexDirection="column">
31
+ <Field label="Purpose" value={summary.purpose} />
32
+ <Field label="Scope" value={summary.scope} />
33
+ <Field label="Impact" value={summary.impact} />
34
+ <Box gap={1}>
35
+ <Box width={10}>
36
+ <Text bold color={T.primary}>Risk:</Text>
37
+ </Box>
38
+ <Text color={riskColor} bold>{riskIcon} {summary.risk_level.toUpperCase()}</Text>
39
+ </Box>
40
+ </Box>
41
+
42
+ <Box flexDirection="column" marginTop={1}>
43
+ <Text bold color={T.faint}>─── Analysis Info ───</Text>
44
+ <Box gap={1}>
45
+ <Text color={T.muted}>Model:</Text>
46
+ <Text color={T.text}>{meta.model_used}</Text>
47
+ </Box>
48
+ <Box gap={1}>
49
+ <Text color={T.muted}>Time:</Text>
50
+ <Text color={T.text}>{new Date(meta.analyzed_at).toLocaleString()}</Text>
51
+ </Box>
52
+ <Box gap={1}>
53
+ <Text color={T.muted}>URL:</Text>
54
+ <Text color={T.primary} underline>{meta.pr_url}</Text>
55
+ </Box>
56
+ </Box>
57
+ </Box>
58
+ );
59
+ }
@@ -0,0 +1,149 @@
1
+ import React, { useState, useMemo } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import type { NewprOutput, FileChange } from "../../types/output.ts";
4
+ import { parseNarrativeAnchors, buildWalkthrough } from "../narrative-parser.ts";
5
+ import { T, STATUS_STYLE, TYPE_STYLE } from "../theme.ts";
6
+
7
+ function FileRow({ file }: { file: FileChange }) {
8
+ const s = STATUS_STYLE[file.status] ?? { icon: "?", color: T.muted };
9
+ return (
10
+ <Box flexDirection="column" marginLeft={2}>
11
+ <Box gap={1}>
12
+ <Text color={s.color} bold>{s.icon}</Text>
13
+ <Text color={T.text}>{file.path}</Text>
14
+ <Text color={T.added}>+{file.additions}</Text>
15
+ <Text color={T.deleted}>-{file.deletions}</Text>
16
+ </Box>
17
+ <Text color={T.muted} wrap="wrap"> {file.summary}</Text>
18
+ </Box>
19
+ );
20
+ }
21
+
22
+ export function WalkthroughPanel({
23
+ data,
24
+ isFocused,
25
+ viewportHeight,
26
+ }: { data: NewprOutput; isFocused: boolean; viewportHeight: number }) {
27
+ const parsed = useMemo(() => parseNarrativeAnchors(data.narrative), [data.narrative]);
28
+ const steps = useMemo(
29
+ () => buildWalkthrough(parsed, data.groups, data.files),
30
+ [parsed, data.groups, data.files],
31
+ );
32
+
33
+ const [currentStep, setCurrentStep] = useState(0);
34
+ const [scrollOffset, setScrollOffset] = useState(0);
35
+
36
+ const step = steps[currentStep];
37
+
38
+ useInput(
39
+ (input, key) => {
40
+ if (key.rightArrow || input === "l" || input === "n") {
41
+ setCurrentStep((s) => Math.min(steps.length - 1, s + 1));
42
+ setScrollOffset(0);
43
+ } else if (key.leftArrow || input === "h" || input === "p") {
44
+ setCurrentStep((s) => Math.max(0, s - 1));
45
+ setScrollOffset(0);
46
+ } else if (key.downArrow || input === "j") {
47
+ setScrollOffset((s) => s + 1);
48
+ } else if (key.upArrow || input === "k") {
49
+ setScrollOffset((s) => Math.max(0, s - 1));
50
+ }
51
+ },
52
+ { isActive: isFocused },
53
+ );
54
+
55
+ if (!step || steps.length === 0) {
56
+ return (
57
+ <Box paddingX={2} paddingY={1}>
58
+ <Text dimColor>No walkthrough steps available.</Text>
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ const progressPct = steps.length > 1
64
+ ? Math.round((currentStep / (steps.length - 1)) * 100)
65
+ : 100;
66
+
67
+ const progressBarWidth = 20;
68
+ const filled = Math.round((progressPct / 100) * progressBarWidth);
69
+ const progressBar = "█".repeat(filled) + "░".repeat(progressBarWidth - filled);
70
+
71
+ const contentLines: React.ReactNode[] = [];
72
+
73
+ contentLines.push(
74
+ <Box key="narrative-header" gap={1} marginBottom={1}>
75
+ <Text bold color={T.primaryBold}>Narrative</Text>
76
+ </Box>,
77
+ );
78
+
79
+ for (let li = 0; li < step.block.lines.length; li++) {
80
+ const line = step.block.lines[li]!;
81
+ contentLines.push(
82
+ <Box key={`line-${li}`} paddingX={1}>
83
+ <Text color={T.text} wrap="wrap">{line}</Text>
84
+ </Box>,
85
+ );
86
+ }
87
+
88
+ if (step.relatedGroups.length > 0) {
89
+ contentLines.push(
90
+ <Box key="groups-header" gap={1} marginTop={1}>
91
+ <Text bold color={T.primary}>Groups</Text>
92
+ </Box>,
93
+ );
94
+
95
+ for (const g of step.relatedGroups) {
96
+ const typeColor = TYPE_STYLE[g.type]?.color ?? T.muted;
97
+ contentLines.push(
98
+ <Box key={`g-${g.name}`} gap={1} marginLeft={1}>
99
+ <Text color={typeColor} bold>{g.name}</Text>
100
+ <Text color={T.muted}>({g.type})</Text>
101
+ <Text color={T.muted}>— {g.description}</Text>
102
+ </Box>,
103
+ );
104
+ }
105
+ }
106
+
107
+ if (step.relatedFiles.length > 0) {
108
+ contentLines.push(
109
+ <Box key="files-header" gap={1} marginTop={1}>
110
+ <Text bold color={T.accent}>Files ({step.relatedFiles.length})</Text>
111
+ </Box>,
112
+ );
113
+
114
+ for (const f of step.relatedFiles) {
115
+ contentLines.push(<FileRow key={`f-${f.path}`} file={f} />);
116
+ }
117
+ }
118
+
119
+ const visibleHeight = Math.max(1, viewportHeight - 5);
120
+ const visible = contentLines.slice(scrollOffset, scrollOffset + visibleHeight);
121
+ const canScrollDown = scrollOffset + visibleHeight < contentLines.length;
122
+
123
+ return (
124
+ <Box flexDirection="column" paddingX={1}>
125
+ <Box gap={1} marginBottom={1}>
126
+ <Text bold color={T.primary}>Walkthrough</Text>
127
+ <Text color={T.faint}>│</Text>
128
+ <Text color={T.muted}>Step {currentStep + 1}/{steps.length}</Text>
129
+ <Text color={T.faint}>│</Text>
130
+ <Text color={T.primary}>{progressBar}</Text>
131
+ <Text color={T.muted}>{progressPct}%</Text>
132
+ </Box>
133
+
134
+ {visible}
135
+
136
+ <Box marginTop={1} gap={1}>
137
+ {scrollOffset > 0 && <Text color={T.faint}>↑</Text>}
138
+ {canScrollDown && <Text color={T.faint}>↓</Text>}
139
+ <Text color={T.faint}>│</Text>
140
+ <Text color={T.primaryBold} bold>←/h</Text>
141
+ <Text color={T.muted}>prev</Text>
142
+ <Text color={T.primaryBold} bold>→/l</Text>
143
+ <Text color={T.muted}>next</Text>
144
+ <Text color={T.primaryBold} bold>j/k</Text>
145
+ <Text color={T.muted}>scroll</Text>
146
+ </Box>
147
+ </Box>
148
+ );
149
+ }
@@ -0,0 +1,62 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { render } 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 { App } from "./App.tsx";
7
+ import { LoadingTimeline, buildStepLog } from "./Loading.tsx";
8
+ import { Shell } from "./Shell.tsx";
9
+
10
+ function LoadingApp({
11
+ resolve,
12
+ }: { resolve: (handlers: LoadingHandlers) => void }) {
13
+ const [data, setData] = useState<NewprOutput | null>(null);
14
+ const eventsRef = useRef<ProgressEvent[]>([]);
15
+ const [steps, setSteps] = useState(buildStepLog([]));
16
+ const [elapsed, setElapsed] = useState(0);
17
+ const startRef = useRef(Date.now());
18
+
19
+ useEffect(() => {
20
+ const timer = setInterval(() => {
21
+ setElapsed(Date.now() - startRef.current);
22
+ }, 500);
23
+ return () => clearInterval(timer);
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ resolve({
28
+ update(event: ProgressEvent) {
29
+ eventsRef.current = [...eventsRef.current, event];
30
+ setSteps(buildStepLog(eventsRef.current));
31
+ },
32
+ finish(result: NewprOutput) {
33
+ setData(result);
34
+ },
35
+ });
36
+ }, [resolve]);
37
+
38
+ if (data) {
39
+ return <App data={data} />;
40
+ }
41
+
42
+ return <LoadingTimeline steps={steps} elapsed={elapsed} />;
43
+ }
44
+
45
+ export interface LoadingHandlers {
46
+ update: (event: ProgressEvent) => void;
47
+ finish: (data: NewprOutput) => void;
48
+ }
49
+
50
+ export function renderTui(data: NewprOutput): void {
51
+ render(<App data={data} />);
52
+ }
53
+
54
+ export function renderLoading(): Promise<LoadingHandlers> {
55
+ return new Promise<LoadingHandlers>((resolve) => {
56
+ render(<LoadingApp resolve={resolve} />);
57
+ });
58
+ }
59
+
60
+ export function renderShell(token: string, config: NewprConfig, initialPr?: string): void {
61
+ render(<Shell token={token} config={config} initialPr={initialPr} />);
62
+ }
@@ -0,0 +1,44 @@
1
+ export const T = {
2
+ primary: "#5fafaf",
3
+ primaryBold: "#87d7d7",
4
+
5
+ text: "#c0c0c0",
6
+ textBold: "#e0e0e0",
7
+ muted: "#808080",
8
+ faint: "#585858",
9
+ border: "#444444",
10
+
11
+ accent: "#d7af5f",
12
+
13
+ ok: "#5faf5f",
14
+ warn: "#d7af5f",
15
+ error: "#d75f5f",
16
+
17
+ added: "#5faf5f",
18
+ deleted: "#d75f5f",
19
+ modified: "#d7af5f",
20
+ renamed: "#5f87af",
21
+ } as const;
22
+
23
+ export const STATUS_STYLE: Record<string, { icon: string; color: string }> = {
24
+ added: { icon: "A", color: T.added },
25
+ modified: { icon: "M", color: T.modified },
26
+ deleted: { icon: "D", color: T.deleted },
27
+ renamed: { icon: "R", color: T.renamed },
28
+ };
29
+
30
+ export const TYPE_STYLE: Record<string, { icon: string; color: string }> = {
31
+ feature: { icon: "~", color: T.primary },
32
+ refactor: { icon: "~", color: T.muted },
33
+ bugfix: { icon: "~", color: T.error },
34
+ chore: { icon: "~", color: T.faint },
35
+ docs: { icon: "~", color: T.muted },
36
+ test: { icon: "~", color: T.ok },
37
+ config: { icon: "~", color: T.accent },
38
+ };
39
+
40
+ export const RISK_COLORS: Record<string, string> = {
41
+ low: T.ok,
42
+ medium: T.warn,
43
+ high: T.error,
44
+ };
@@ -0,0 +1,19 @@
1
+ import type { AgentToolName } from "../workspace/types.ts";
2
+
3
+ export interface NewprConfig {
4
+ openrouter_api_key: string;
5
+ model: string;
6
+ max_files: number;
7
+ timeout: number;
8
+ concurrency: number;
9
+ language: string;
10
+ agent?: AgentToolName;
11
+ }
12
+
13
+ export const DEFAULT_CONFIG: Omit<NewprConfig, "openrouter_api_key"> = {
14
+ model: "anthropic/claude-sonnet-4.5",
15
+ max_files: 100,
16
+ timeout: 120,
17
+ concurrency: 5,
18
+ language: "auto",
19
+ };
@@ -0,0 +1,36 @@
1
+ import type { FileStatus } from "./output.ts";
2
+
3
+ export interface DiffHunk {
4
+ old_start: number;
5
+ old_count: number;
6
+ new_start: number;
7
+ new_count: number;
8
+ content: string;
9
+ }
10
+
11
+ export interface FileDiff {
12
+ path: string;
13
+ old_path: string | null;
14
+ status: FileStatus;
15
+ additions: number;
16
+ deletions: number;
17
+ is_binary: boolean;
18
+ hunks: DiffHunk[];
19
+ raw: string;
20
+ }
21
+
22
+ export interface ParsedDiff {
23
+ files: FileDiff[];
24
+ total_additions: number;
25
+ total_deletions: number;
26
+ }
27
+
28
+ export interface DiffChunk {
29
+ file_path: string;
30
+ status: FileStatus;
31
+ additions: number;
32
+ deletions: number;
33
+ is_binary: boolean;
34
+ diff_content: string;
35
+ estimated_tokens: number;
36
+ }
@@ -0,0 +1,28 @@
1
+ export interface PrIdentifier {
2
+ owner: string;
3
+ repo: string;
4
+ number: number;
5
+ }
6
+
7
+ export interface PrCommit {
8
+ sha: string;
9
+ message: string;
10
+ author: string;
11
+ date: string;
12
+ files: string[];
13
+ }
14
+
15
+ export interface GithubPrData {
16
+ number: number;
17
+ title: string;
18
+ url: string;
19
+ base_branch: string;
20
+ head_branch: string;
21
+ author: string;
22
+ author_avatar?: string;
23
+ author_url?: string;
24
+ additions: number;
25
+ deletions: number;
26
+ changed_files: number;
27
+ commits: PrCommit[];
28
+ }