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,75 @@
|
|
|
1
|
+
import type { NewprConfig } from "../types/config.ts";
|
|
2
|
+
import type { AgentToolName } from "../workspace/types.ts";
|
|
3
|
+
import { writeStoredConfig } from "../config/store.ts";
|
|
4
|
+
import { resolveLanguage } from "../config/index.ts";
|
|
5
|
+
|
|
6
|
+
export interface SlashCmd {
|
|
7
|
+
name: string;
|
|
8
|
+
args?: string;
|
|
9
|
+
desc: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const SLASH_CMDS: SlashCmd[] = [
|
|
13
|
+
{ name: "language", args: "<lang>", desc: "Set output language (auto, Korean, English, ...)" },
|
|
14
|
+
{ name: "agent", args: "<name>", desc: "Set exploration agent (auto, claude, opencode, codex)" },
|
|
15
|
+
{ name: "auth", args: "<key>", desc: "Set OpenRouter API key" },
|
|
16
|
+
{ name: "model", args: "<name>", desc: "Set LLM model" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const VALID_AGENTS = new Set<string>(["claude", "opencode", "codex", "auto"]);
|
|
20
|
+
|
|
21
|
+
export function filterCommands(input: string): SlashCmd[] {
|
|
22
|
+
const prefix = input.split(/\s/)[0]!.toLowerCase();
|
|
23
|
+
return SLASH_CMDS.filter((c) => `/${c.name}`.startsWith(prefix));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CmdResult {
|
|
27
|
+
text: string;
|
|
28
|
+
ok: boolean;
|
|
29
|
+
configUpdate?: Partial<NewprConfig>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function executeCommand(
|
|
33
|
+
input: string,
|
|
34
|
+
config: NewprConfig,
|
|
35
|
+
): Promise<CmdResult> {
|
|
36
|
+
const parts = input.slice(1).split(/\s+/);
|
|
37
|
+
const cmd = parts[0]?.toLowerCase() ?? "";
|
|
38
|
+
const args = parts.slice(1).join(" ").trim();
|
|
39
|
+
|
|
40
|
+
switch (cmd) {
|
|
41
|
+
case "language":
|
|
42
|
+
case "lang": {
|
|
43
|
+
if (!args) return { text: `Language: ${config.language}`, ok: true };
|
|
44
|
+
const resolved = resolveLanguage(args);
|
|
45
|
+
await writeStoredConfig({ language: args });
|
|
46
|
+
return { text: `Language → ${resolved}`, ok: true, configUpdate: { language: resolved } };
|
|
47
|
+
}
|
|
48
|
+
case "agent": {
|
|
49
|
+
if (!args) return { text: `Agent: ${config.agent ?? "auto"}`, ok: true };
|
|
50
|
+
if (!VALID_AGENTS.has(args.toLowerCase())) {
|
|
51
|
+
return { text: `Unknown agent: ${args}. Use: auto, claude, opencode, codex`, ok: false };
|
|
52
|
+
}
|
|
53
|
+
const val = args.toLowerCase() === "auto" ? undefined : args.toLowerCase() as AgentToolName;
|
|
54
|
+
await writeStoredConfig({ agent: val });
|
|
55
|
+
return { text: `Agent → ${args.toLowerCase()}`, ok: true, configUpdate: { agent: val } };
|
|
56
|
+
}
|
|
57
|
+
case "auth": {
|
|
58
|
+
if (!args) {
|
|
59
|
+
const masked = config.openrouter_api_key
|
|
60
|
+
? `${config.openrouter_api_key.slice(0, 10)}${"*".repeat(8)}`
|
|
61
|
+
: "(not set)";
|
|
62
|
+
return { text: `API key: ${masked}`, ok: true };
|
|
63
|
+
}
|
|
64
|
+
await writeStoredConfig({ openrouter_api_key: args });
|
|
65
|
+
return { text: "API key updated", ok: true, configUpdate: { openrouter_api_key: args } };
|
|
66
|
+
}
|
|
67
|
+
case "model": {
|
|
68
|
+
if (!args) return { text: `Model: ${config.model}`, ok: true };
|
|
69
|
+
await writeStoredConfig({ model: args });
|
|
70
|
+
return { text: `Model → ${args}`, ok: true, configUpdate: { model: args } };
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return { text: `Unknown command: /${cmd}`, ok: false };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { FileGroup, FileChange } from "../types/output.ts";
|
|
2
|
+
|
|
3
|
+
export type AnchorKind = "group" | "file";
|
|
4
|
+
|
|
5
|
+
export interface NarrativeAnchor {
|
|
6
|
+
kind: AnchorKind;
|
|
7
|
+
id: string;
|
|
8
|
+
lineIndex: number;
|
|
9
|
+
startCol: number;
|
|
10
|
+
endCol: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NarrativeBlock {
|
|
14
|
+
lines: string[];
|
|
15
|
+
startLine: number;
|
|
16
|
+
anchors: NarrativeAnchor[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParsedNarrative {
|
|
20
|
+
blocks: NarrativeBlock[];
|
|
21
|
+
allAnchors: NarrativeAnchor[];
|
|
22
|
+
displayLines: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ANCHOR_RE = /\[\[(group|file):(.+?)\]\]/g;
|
|
26
|
+
|
|
27
|
+
export function parseNarrativeAnchors(narrative: string): ParsedNarrative {
|
|
28
|
+
const rawLines = narrative.split("\n");
|
|
29
|
+
const displayLines: string[] = [];
|
|
30
|
+
const allAnchors: NarrativeAnchor[] = [];
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
33
|
+
const raw = rawLines[i]!;
|
|
34
|
+
let display = "";
|
|
35
|
+
let lastIndex = 0;
|
|
36
|
+
|
|
37
|
+
ANCHOR_RE.lastIndex = 0;
|
|
38
|
+
let match: RegExpExecArray | null;
|
|
39
|
+
|
|
40
|
+
while ((match = ANCHOR_RE.exec(raw)) !== null) {
|
|
41
|
+
display += raw.slice(lastIndex, match.index);
|
|
42
|
+
const startCol = display.length;
|
|
43
|
+
const kind = match[1] as AnchorKind;
|
|
44
|
+
const id = match[2]!;
|
|
45
|
+
const label = kind === "file" ? id.split("/").pop()! : id;
|
|
46
|
+
display += label;
|
|
47
|
+
|
|
48
|
+
allAnchors.push({
|
|
49
|
+
kind,
|
|
50
|
+
id,
|
|
51
|
+
lineIndex: i,
|
|
52
|
+
startCol,
|
|
53
|
+
endCol: startCol + label.length,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
lastIndex = match.index + match[0].length;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
display += raw.slice(lastIndex);
|
|
60
|
+
displayLines.push(display);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const blocks = buildBlocks(displayLines, allAnchors);
|
|
64
|
+
return { blocks, allAnchors, displayLines };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildBlocks(lines: string[], anchors: NarrativeAnchor[]): NarrativeBlock[] {
|
|
68
|
+
const blocks: NarrativeBlock[] = [];
|
|
69
|
+
let current: NarrativeBlock | null = null;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const line = lines[i]!;
|
|
73
|
+
const isEmpty = line.trim() === "";
|
|
74
|
+
const isHeading = /^#{1,3}\s/.test(line);
|
|
75
|
+
|
|
76
|
+
if ((isEmpty || isHeading) && current) {
|
|
77
|
+
blocks.push(current);
|
|
78
|
+
current = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (isEmpty) continue;
|
|
82
|
+
|
|
83
|
+
if (!current) {
|
|
84
|
+
current = { lines: [], startLine: i, anchors: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
current.lines.push(line);
|
|
88
|
+
const lineAnchors = anchors.filter((a) => a.lineIndex === i);
|
|
89
|
+
current.anchors.push(...lineAnchors);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (current) blocks.push(current);
|
|
93
|
+
return blocks;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface WalkthroughStep {
|
|
97
|
+
type: "narrative";
|
|
98
|
+
blockIndex: number;
|
|
99
|
+
block: NarrativeBlock;
|
|
100
|
+
relatedGroups: FileGroup[];
|
|
101
|
+
relatedFiles: FileChange[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildWalkthrough(
|
|
105
|
+
parsed: ParsedNarrative,
|
|
106
|
+
groups: FileGroup[],
|
|
107
|
+
files: FileChange[],
|
|
108
|
+
): WalkthroughStep[] {
|
|
109
|
+
const groupMap = new Map(groups.map((g) => [g.name, g]));
|
|
110
|
+
const fileMap = new Map(files.map((f) => [f.path, f]));
|
|
111
|
+
|
|
112
|
+
return parsed.blocks.map((block, i) => {
|
|
113
|
+
const relatedGroups: FileGroup[] = [];
|
|
114
|
+
const relatedFiles: FileChange[] = [];
|
|
115
|
+
const seenGroups = new Set<string>();
|
|
116
|
+
const seenFiles = new Set<string>();
|
|
117
|
+
|
|
118
|
+
for (const anchor of block.anchors) {
|
|
119
|
+
if (anchor.kind === "group") {
|
|
120
|
+
const group = groupMap.get(anchor.id);
|
|
121
|
+
if (group && !seenGroups.has(anchor.id)) {
|
|
122
|
+
seenGroups.add(anchor.id);
|
|
123
|
+
relatedGroups.push(group);
|
|
124
|
+
for (const fp of group.files) {
|
|
125
|
+
if (!seenFiles.has(fp)) {
|
|
126
|
+
seenFiles.add(fp);
|
|
127
|
+
const file = fileMap.get(fp);
|
|
128
|
+
if (file) relatedFiles.push(file);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
if (!seenFiles.has(anchor.id)) {
|
|
134
|
+
seenFiles.add(anchor.id);
|
|
135
|
+
const file = fileMap.get(anchor.id);
|
|
136
|
+
if (file) relatedFiles.push(file);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { type: "narrative" as const, blockIndex: i, block, relatedGroups, relatedFiles };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { FileChange } from "../../types/output.ts";
|
|
4
|
+
import { T, STATUS_STYLE } from "../theme.ts";
|
|
5
|
+
|
|
6
|
+
export function FilesPanel({
|
|
7
|
+
files,
|
|
8
|
+
isFocused,
|
|
9
|
+
viewportHeight,
|
|
10
|
+
}: { files: FileChange[]; isFocused: boolean; viewportHeight: number }) {
|
|
11
|
+
const [selected, setSelected] = useState(0);
|
|
12
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
13
|
+
const [filterMode, setFilterMode] = useState(false);
|
|
14
|
+
const [filterText, setFilterText] = useState("");
|
|
15
|
+
|
|
16
|
+
const filtered = useMemo(() => {
|
|
17
|
+
if (!filterText) return files;
|
|
18
|
+
const lower = filterText.toLowerCase();
|
|
19
|
+
return files.filter(
|
|
20
|
+
(f) => f.path.toLowerCase().includes(lower) || f.summary.toLowerCase().includes(lower),
|
|
21
|
+
);
|
|
22
|
+
}, [files, filterText]);
|
|
23
|
+
|
|
24
|
+
const linesPerFile = 2;
|
|
25
|
+
const headerLines = (filterMode || filterText) ? 1 : 0;
|
|
26
|
+
const maxVisible = Math.max(1, Math.floor((viewportHeight - 2 - headerLines) / linesPerFile));
|
|
27
|
+
|
|
28
|
+
function ensureVisible(idx: number) {
|
|
29
|
+
if (idx < scrollOffset) {
|
|
30
|
+
setScrollOffset(idx);
|
|
31
|
+
} else if (idx >= scrollOffset + maxVisible) {
|
|
32
|
+
setScrollOffset(idx - maxVisible + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
useInput(
|
|
37
|
+
(input, key) => {
|
|
38
|
+
if (filterMode) {
|
|
39
|
+
if (key.escape || key.return) {
|
|
40
|
+
setFilterMode(false);
|
|
41
|
+
setSelected(0);
|
|
42
|
+
setScrollOffset(0);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (key.backspace || key.delete) {
|
|
46
|
+
setFilterText((t) => t.slice(0, -1));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (input && !key.ctrl && !key.meta) {
|
|
50
|
+
setFilterText((t) => t + input);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (input === "/") {
|
|
57
|
+
setFilterMode(true);
|
|
58
|
+
setFilterText("");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (key.escape) {
|
|
62
|
+
setFilterText("");
|
|
63
|
+
setSelected(0);
|
|
64
|
+
setScrollOffset(0);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.upArrow || input === "k") {
|
|
68
|
+
setSelected((s) => {
|
|
69
|
+
const next = Math.max(0, s - 1);
|
|
70
|
+
ensureVisible(next);
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
} else if (key.downArrow || input === "j") {
|
|
74
|
+
setSelected((s) => {
|
|
75
|
+
const next = Math.min(filtered.length - 1, s + 1);
|
|
76
|
+
ensureVisible(next);
|
|
77
|
+
return next;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{ isActive: isFocused },
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const visible = filtered.slice(scrollOffset, scrollOffset + maxVisible);
|
|
85
|
+
const canScrollUp = scrollOffset > 0;
|
|
86
|
+
const canScrollDown = scrollOffset + maxVisible < filtered.length;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box flexDirection="column" paddingX={1}>
|
|
90
|
+
{filterMode && (
|
|
91
|
+
<Box>
|
|
92
|
+
<Text color={T.primary} bold>/ </Text>
|
|
93
|
+
<Text color={T.text}>{filterText}</Text>
|
|
94
|
+
<Text color={T.primary}>█</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
)}
|
|
97
|
+
{!filterMode && filterText && (
|
|
98
|
+
<Text color={T.muted}>
|
|
99
|
+
Filtered: <Text color={T.accent}>"{filterText}"</Text> ({filtered.length}/{files.length}) — <Text color={T.primaryBold}>Esc</Text> to clear
|
|
100
|
+
</Text>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{canScrollUp && <Text color={T.faint}> ↑ {scrollOffset} more above</Text>}
|
|
104
|
+
|
|
105
|
+
{filtered.length === 0 && (
|
|
106
|
+
<Text color={T.muted}>No files {filterText ? "matching filter" : "found"}.</Text>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{visible.map((file, vi) => {
|
|
110
|
+
const isSelected = scrollOffset + vi === selected;
|
|
111
|
+
const ss = STATUS_STYLE[file.status] ?? { icon: "?", color: T.muted };
|
|
112
|
+
return (
|
|
113
|
+
<Box key={file.path} flexDirection="column">
|
|
114
|
+
<Text inverse={isSelected && isFocused} bold={isSelected}>
|
|
115
|
+
{" "}
|
|
116
|
+
<Text color={isSelected && isFocused ? undefined : ss.color} bold>{ss.icon}</Text>
|
|
117
|
+
{" "}
|
|
118
|
+
<Text color={isSelected && isFocused ? undefined : T.text}>{file.path}</Text>
|
|
119
|
+
{" "}
|
|
120
|
+
<Text color={isSelected && isFocused ? undefined : T.added}>+{file.additions}</Text>
|
|
121
|
+
<Text color={isSelected && isFocused ? undefined : T.deleted}>-{file.deletions}</Text>
|
|
122
|
+
</Text>
|
|
123
|
+
<Box paddingLeft={4}>
|
|
124
|
+
<Text color={T.muted}>{file.summary}</Text>
|
|
125
|
+
<Text color={T.faint}> [{file.groups.join(", ")}]</Text>
|
|
126
|
+
</Box>
|
|
127
|
+
</Box>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
|
|
131
|
+
{canScrollDown && <Text color={T.faint}> ↓ {filtered.length - scrollOffset - maxVisible} more below</Text>}
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import type { FileGroup, FileChange } from "../../types/output.ts";
|
|
4
|
+
import { T, TYPE_STYLE, STATUS_STYLE } from "../theme.ts";
|
|
5
|
+
|
|
6
|
+
interface RenderedLine {
|
|
7
|
+
key: string;
|
|
8
|
+
node: React.ReactNode;
|
|
9
|
+
groupIndex: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function GroupsPanel({
|
|
13
|
+
groups,
|
|
14
|
+
files,
|
|
15
|
+
isFocused,
|
|
16
|
+
viewportHeight,
|
|
17
|
+
}: { groups: FileGroup[]; files: FileChange[]; isFocused: boolean; viewportHeight: number }) {
|
|
18
|
+
const [selected, setSelected] = useState(0);
|
|
19
|
+
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
|
20
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
21
|
+
|
|
22
|
+
const toggle = useCallback(() => {
|
|
23
|
+
setExpanded((prev) => {
|
|
24
|
+
const next = new Set(prev);
|
|
25
|
+
if (next.has(selected)) next.delete(selected);
|
|
26
|
+
else next.add(selected);
|
|
27
|
+
return next;
|
|
28
|
+
});
|
|
29
|
+
}, [selected]);
|
|
30
|
+
|
|
31
|
+
const allLines = useMemo(() => {
|
|
32
|
+
const lines: RenderedLine[] = [];
|
|
33
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
34
|
+
const group = groups[gi]!;
|
|
35
|
+
const isExpanded = expanded.has(gi);
|
|
36
|
+
const style = TYPE_STYLE[group.type] ?? { icon: "•", color: T.muted };
|
|
37
|
+
const arrow = isExpanded ? "▼" : "▶";
|
|
38
|
+
const isSelected = gi === selected;
|
|
39
|
+
|
|
40
|
+
lines.push({
|
|
41
|
+
key: `g-${gi}`,
|
|
42
|
+
groupIndex: gi,
|
|
43
|
+
node: (
|
|
44
|
+
<Text inverse={isSelected && isFocused} bold={isSelected}>
|
|
45
|
+
{" "}<Text color={isSelected && isFocused ? undefined : T.faint}>{arrow}</Text>
|
|
46
|
+
{" "}<Text color={isSelected && isFocused ? undefined : style.color}>{style.icon}</Text>
|
|
47
|
+
{" "}{group.name}
|
|
48
|
+
{" "}<Text color={isSelected && isFocused ? undefined : T.muted}>({group.files.length})</Text>
|
|
49
|
+
{" "}<Text color={isSelected && isFocused ? undefined : T.faint} dimColor>{group.type}</Text>
|
|
50
|
+
</Text>
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!isExpanded) {
|
|
55
|
+
lines.push({
|
|
56
|
+
key: `g-${gi}-desc`,
|
|
57
|
+
groupIndex: gi,
|
|
58
|
+
node: <Text color={T.muted}> {group.description}</Text>,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
lines.push({
|
|
62
|
+
key: `g-${gi}-desc-e`,
|
|
63
|
+
groupIndex: gi,
|
|
64
|
+
node: <Text color={T.muted} italic> {group.description}</Text>,
|
|
65
|
+
});
|
|
66
|
+
const groupFiles = files.filter((f) => group.files.includes(f.path));
|
|
67
|
+
for (const f of groupFiles) {
|
|
68
|
+
const fs = STATUS_STYLE[f.status] ?? { icon: "?", color: T.muted };
|
|
69
|
+
lines.push({
|
|
70
|
+
key: `g-${gi}-f-${f.path}`,
|
|
71
|
+
groupIndex: gi,
|
|
72
|
+
node: (
|
|
73
|
+
<Box gap={1} marginLeft={4}>
|
|
74
|
+
<Text color={fs.color} bold>{fs.icon}</Text>
|
|
75
|
+
<Text color={T.text}>{f.path}</Text>
|
|
76
|
+
<Text color={T.added}>+{f.additions}</Text>
|
|
77
|
+
<Text color={T.deleted}>-{f.deletions}</Text>
|
|
78
|
+
</Box>
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lines;
|
|
85
|
+
}, [groups, files, expanded, selected, isFocused]);
|
|
86
|
+
|
|
87
|
+
useInput(
|
|
88
|
+
(input, key) => {
|
|
89
|
+
if (key.upArrow || input === "k") {
|
|
90
|
+
setSelected((s) => {
|
|
91
|
+
const next = Math.max(0, s - 1);
|
|
92
|
+
ensureVisible(next);
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
} else if (key.downArrow || input === "j") {
|
|
96
|
+
setSelected((s) => {
|
|
97
|
+
const next = Math.min(groups.length - 1, s + 1);
|
|
98
|
+
ensureVisible(next);
|
|
99
|
+
return next;
|
|
100
|
+
});
|
|
101
|
+
} else if (key.return) {
|
|
102
|
+
toggle();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{ isActive: isFocused },
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
function ensureVisible(groupIdx: number) {
|
|
109
|
+
const firstLineIdx = allLines.findIndex((l) => l.groupIndex === groupIdx);
|
|
110
|
+
if (firstLineIdx === -1) return;
|
|
111
|
+
|
|
112
|
+
if (firstLineIdx < scrollOffset) {
|
|
113
|
+
setScrollOffset(firstLineIdx);
|
|
114
|
+
} else if (firstLineIdx >= scrollOffset + viewportHeight - 2) {
|
|
115
|
+
setScrollOffset(Math.max(0, firstLineIdx - viewportHeight + 4));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (groups.length === 0) {
|
|
120
|
+
return (
|
|
121
|
+
<Box paddingX={2} paddingY={1}>
|
|
122
|
+
<Text dimColor>No change groups found.</Text>
|
|
123
|
+
</Box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const visible = allLines.slice(scrollOffset, scrollOffset + viewportHeight - 2);
|
|
128
|
+
const canScrollUp = scrollOffset > 0;
|
|
129
|
+
const canScrollDown = scrollOffset + viewportHeight - 2 < allLines.length;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Box flexDirection="column" paddingX={1}>
|
|
133
|
+
{canScrollUp && <Text color={T.faint}> ↑ more above</Text>}
|
|
134
|
+
{visible.map((line) => (
|
|
135
|
+
<Box key={line.key}>{line.node}</Box>
|
|
136
|
+
))}
|
|
137
|
+
{canScrollDown && <Text color={T.faint}> ↓ more below</Text>}
|
|
138
|
+
</Box>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { T } from "../theme.ts";
|
|
4
|
+
|
|
5
|
+
function renderMarkdownLine(line: string) {
|
|
6
|
+
if (line.startsWith("### ")) {
|
|
7
|
+
return <Text bold color={T.primaryBold}>{line.slice(4)}</Text>;
|
|
8
|
+
}
|
|
9
|
+
if (line.startsWith("## ")) {
|
|
10
|
+
return <Text bold color={T.primary} underline>{line.slice(3)}</Text>;
|
|
11
|
+
}
|
|
12
|
+
if (line.startsWith("# ")) {
|
|
13
|
+
return <Text bold color={T.primary} underline>{line.slice(2)}</Text>;
|
|
14
|
+
}
|
|
15
|
+
if (line.startsWith("- ") || line.startsWith("* ")) {
|
|
16
|
+
return <Text><Text color={T.primary}> •</Text> {line.slice(2)}</Text>;
|
|
17
|
+
}
|
|
18
|
+
if (line.trim() === "") {
|
|
19
|
+
return <Text> </Text>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parts: React.JSX.Element[] = [];
|
|
23
|
+
let remaining = line;
|
|
24
|
+
let keyIdx = 0;
|
|
25
|
+
|
|
26
|
+
while (remaining.length > 0) {
|
|
27
|
+
const boldMatch = remaining.match(/\*\*(.+?)\*\*/);
|
|
28
|
+
const codeMatch = remaining.match(/`(.+?)`/);
|
|
29
|
+
|
|
30
|
+
let firstMatch: { index: number; full: string; content: string; type: "bold" | "code" } | null = null;
|
|
31
|
+
|
|
32
|
+
if (boldMatch?.index !== undefined) {
|
|
33
|
+
firstMatch = { index: boldMatch.index, full: boldMatch[0], content: boldMatch[1]!, type: "bold" };
|
|
34
|
+
}
|
|
35
|
+
if (codeMatch?.index !== undefined) {
|
|
36
|
+
if (!firstMatch || codeMatch.index < firstMatch.index) {
|
|
37
|
+
firstMatch = { index: codeMatch.index, full: codeMatch[0], content: codeMatch[1]!, type: "code" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!firstMatch) {
|
|
42
|
+
parts.push(<Text key={keyIdx++} color={T.text}>{remaining}</Text>);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (firstMatch.index > 0) {
|
|
47
|
+
parts.push(<Text key={keyIdx++} color={T.text}>{remaining.slice(0, firstMatch.index)}</Text>);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (firstMatch.type === "bold") {
|
|
51
|
+
parts.push(<Text key={keyIdx++} bold color={T.textBold}>{firstMatch.content}</Text>);
|
|
52
|
+
} else {
|
|
53
|
+
parts.push(<Text key={keyIdx++} color={T.accent}>{firstMatch.content}</Text>);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
remaining = remaining.slice(firstMatch.index + firstMatch.full.length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return <Text>{...parts}</Text>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function NarrativePanel({
|
|
63
|
+
narrative,
|
|
64
|
+
isFocused,
|
|
65
|
+
viewportHeight,
|
|
66
|
+
}: { narrative: string; isFocused: boolean; viewportHeight: number }) {
|
|
67
|
+
const lines = narrative.split("\n");
|
|
68
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
69
|
+
const maxScroll = Math.max(0, lines.length - viewportHeight + 3);
|
|
70
|
+
|
|
71
|
+
useInput(
|
|
72
|
+
(input, key) => {
|
|
73
|
+
if (key.upArrow || input === "k") {
|
|
74
|
+
setScrollOffset((s) => Math.max(0, s - 1));
|
|
75
|
+
} else if (key.downArrow || input === "j") {
|
|
76
|
+
setScrollOffset((s) => Math.min(maxScroll, s + 1));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{ isActive: isFocused },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const visibleCount = Math.max(1, viewportHeight - 3);
|
|
83
|
+
const visible = lines.slice(scrollOffset, scrollOffset + visibleCount);
|
|
84
|
+
const canScrollUp = scrollOffset > 0;
|
|
85
|
+
const canScrollDown = scrollOffset < maxScroll;
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Box flexDirection="column" paddingX={2}>
|
|
89
|
+
<Box gap={1}>
|
|
90
|
+
<Text bold color={T.primary}>¶ Change Narrative</Text>
|
|
91
|
+
{(canScrollUp || canScrollDown) && (
|
|
92
|
+
<Text color={T.faint}>({scrollOffset + 1}-{Math.min(scrollOffset + visibleCount, lines.length)}/{lines.length})</Text>
|
|
93
|
+
)}
|
|
94
|
+
</Box>
|
|
95
|
+
{canScrollUp && <Text color={T.faint}>↑</Text>}
|
|
96
|
+
{visible.map((line, i) => (
|
|
97
|
+
<Box key={scrollOffset + i}>{renderMarkdownLine(line)}</Box>
|
|
98
|
+
))}
|
|
99
|
+
{canScrollDown && <Text color={T.faint}>↓</Text>}
|
|
100
|
+
</Box>
|
|
101
|
+
);
|
|
102
|
+
}
|