schub 0.1.2 → 0.1.4

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 (80) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +12830 -3057
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +5 -1
  5. package/skills/create-tasks/SKILL.md +5 -4
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +3 -2
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +166 -0
  10. package/src/changes.ts +159 -54
  11. package/src/commands/adr.test.ts +6 -5
  12. package/src/commands/changes.test.ts +136 -14
  13. package/src/commands/changes.ts +102 -1
  14. package/src/commands/cookbook.test.ts +6 -5
  15. package/src/commands/init.test.ts +69 -2
  16. package/src/commands/init.ts +48 -5
  17. package/src/commands/review.test.ts +7 -6
  18. package/src/commands/review.ts +1 -1
  19. package/src/commands/roadmap.test.ts +84 -0
  20. package/src/commands/roadmap.ts +84 -0
  21. package/src/commands/tasks-create.test.ts +22 -22
  22. package/src/commands/tasks-implement.test.ts +253 -0
  23. package/src/commands/tasks-implement.ts +121 -0
  24. package/src/commands/tasks-list.test.ts +27 -27
  25. package/src/commands/tasks-update.test.ts +92 -0
  26. package/src/commands/tasks.ts +98 -1
  27. package/src/features/roadmap/index.ts +230 -0
  28. package/src/features/roadmap/roadmap.test.ts +77 -0
  29. package/src/features/tasks/constants.ts +1 -0
  30. package/src/features/tasks/create.ts +10 -8
  31. package/src/features/tasks/filesystem.test.ts +285 -18
  32. package/src/features/tasks/filesystem.ts +152 -39
  33. package/src/features/tasks/graph.ts +18 -3
  34. package/src/features/tasks/index.ts +10 -1
  35. package/src/features/tasks/worktree.ts +48 -0
  36. package/src/frontmatter.ts +115 -0
  37. package/src/index.test.ts +42 -6
  38. package/src/index.ts +226 -109
  39. package/src/opencode.test.ts +53 -0
  40. package/src/opencode.ts +74 -0
  41. package/src/tasks.ts +2 -0
  42. package/src/tui/App.test.tsx +418 -0
  43. package/src/tui/App.tsx +343 -0
  44. package/src/tui/components/PlanView.test.tsx +101 -0
  45. package/src/tui/components/PlanView.tsx +89 -0
  46. package/src/tui/components/PreviewPage.test.tsx +69 -0
  47. package/src/tui/components/PreviewPage.tsx +87 -0
  48. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  49. package/src/tui/components/ProposalDetailView.tsx +166 -0
  50. package/src/tui/components/RoadmapView.test.tsx +85 -0
  51. package/src/tui/components/RoadmapView.tsx +369 -0
  52. package/src/tui/components/StatusView.test.tsx +1351 -0
  53. package/src/tui/components/StatusView.tsx +519 -0
  54. package/src/tui/components/markdown-renderer.test.ts +46 -0
  55. package/src/tui/components/markdown-renderer.ts +89 -0
  56. package/src/tui/components/status-view-data.ts +322 -0
  57. package/src/tui/components/status-view-render.tsx +329 -0
  58. package/src/tui/index.ts +16 -0
  59. package/templates/create-proposal/adr-template.md +6 -4
  60. package/templates/create-proposal/cookbook-template.md +5 -3
  61. package/templates/create-proposal/proposal-template.md +8 -6
  62. package/templates/create-roadmap/roadmap.md +5 -0
  63. package/templates/create-tasks/task-template.md +9 -4
  64. package/templates/review-proposal/q&a-template.md +8 -3
  65. package/templates/review-proposal/review-me-template.md +6 -4
  66. package/templates/setup-project/project-overview-template.md +5 -0
  67. package/templates/setup-project/project-setup-template.md +5 -0
  68. package/templates/setup-project/project-wow-template.md +5 -0
  69. package/src/App.test.tsx +0 -93
  70. package/src/App.tsx +0 -155
  71. package/src/components/PlanView.test.tsx +0 -113
  72. package/src/components/PlanView.tsx +0 -160
  73. package/src/components/StatusView.test.tsx +0 -380
  74. package/src/components/StatusView.tsx +0 -367
  75. package/src/ide.ts +0 -7
  76. package/templates/templates-parity.test.ts +0 -45
  77. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  78. /package/src/{components → tui/components}/statusColor.ts +0 -0
  79. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  80. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -0,0 +1,343 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { Box, Text, useInput, useStdout } from "ink";
5
+ import React from "react";
6
+ import packageJson from "../../package.json";
7
+ import { findSchubRoot, listTasksForChange, TASK_STATUSES, type TaskInfo, trimTaskTitle } from "../features/tasks";
8
+ import { copyToClipboard as copyToClipboardDefault } from "./clipboard";
9
+ import PlanView from "./components/PlanView";
10
+ import PreviewPage from "./components/PreviewPage";
11
+ import ProposalDetailView from "./components/ProposalDetailView";
12
+ import RoadmapView from "./components/RoadmapView";
13
+ import StatusView from "./components/StatusView";
14
+
15
+ type Mode = "status" | "plan" | "roadmap";
16
+
17
+ type TabDefinition = {
18
+ id: Mode;
19
+ label: string;
20
+ };
21
+
22
+ type Shortcut = {
23
+ keyLabel: string;
24
+ label: string;
25
+ };
26
+
27
+ type PreviewState = {
28
+ fileName: string;
29
+ markdown: string;
30
+ };
31
+
32
+ type ProposalDetailState = {
33
+ changeId: string;
34
+ selection: number;
35
+ };
36
+
37
+ const tabs: TabDefinition[] = [
38
+ { id: "status", label: "Status" },
39
+ { id: "plan", label: "Plan" },
40
+ { id: "roadmap", label: "Roadmap" },
41
+ ];
42
+
43
+ type AppProps = {
44
+ copyToClipboard?: (value: string) => void;
45
+ onImplement?: (taskId: string, repoRoot: string) => void;
46
+ startDir?: string;
47
+ };
48
+
49
+ const COPY_BANNER_TEXT = "Copied to clipboard !";
50
+ const COPY_BANNER_TIMEOUT_MS = 1500;
51
+ const OPEN_SHORTCUT: Shortcut = { keyLabel: "o", label: "preview" };
52
+ const COPY_SHORTCUT: Shortcut = { keyLabel: "c", label: "copy id" };
53
+
54
+ const compareText = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" });
55
+
56
+ const sortDetailTasks = (tasks: TaskInfo[]) =>
57
+ [...tasks].sort((left, right) => {
58
+ const leftTitle = trimTaskTitle(left.title) || left.id;
59
+ const rightTitle = trimTaskTitle(right.title) || right.id;
60
+ const titleCompare = compareText(leftTitle, rightTitle);
61
+ if (titleCompare !== 0) {
62
+ return titleCompare;
63
+ }
64
+ return compareText(left.id, right.id);
65
+ });
66
+
67
+ const buildDetailTasks = (tasks: TaskInfo[]) =>
68
+ TASK_STATUSES.flatMap((status) => sortDetailTasks(tasks.filter((task) => task.status === status)));
69
+
70
+ export default function App({ copyToClipboard = copyToClipboardDefault, onImplement, startDir }: AppProps) {
71
+ const [mode, setMode] = React.useState<Mode>("status");
72
+ const { stdout } = useStdout();
73
+ const [dimensions, setDimensions] = React.useState(() => ({
74
+ columns: stdout.columns,
75
+ rows: stdout.rows,
76
+ }));
77
+ const [copyBanner, setCopyBanner] = React.useState<string | null>(null);
78
+ const [statusShortcuts, setStatusShortcuts] = React.useState<Shortcut[]>([]);
79
+ const [statusView, setStatusView] = React.useState<"main" | "show-all" | "show-all-tasks">("main");
80
+ const [preview, setPreview] = React.useState<PreviewState | null>(null);
81
+ const [proposalDetail, setProposalDetail] = React.useState<ProposalDetailState | null>(null);
82
+ const versionLabel = `${packageJson.version}`;
83
+ const homeDir = homedir();
84
+ const resolvedStartDir = startDir ?? process.env.SCHUB_CWD ?? process.cwd();
85
+ const schubDir = findSchubRoot(resolvedStartDir);
86
+ const displaySchubDir = (() => {
87
+ const targetDir = schubDir ?? resolvedStartDir;
88
+ if (targetDir === homeDir) {
89
+ return "~";
90
+ }
91
+ if (targetDir.startsWith(`${homeDir}/`)) {
92
+ return `~${targetDir.slice(homeDir.length)}`;
93
+ }
94
+ return targetDir;
95
+ })();
96
+ const repoRoot = schubDir ? dirname(schubDir) : "";
97
+ const isPreviewActive = Boolean(preview);
98
+ const isDetailActive = Boolean(proposalDetail);
99
+ const detailSelection = proposalDetail?.selection ?? 0;
100
+ const detailTasks =
101
+ proposalDetail && schubDir ? buildDetailTasks(listTasksForChange(schubDir, proposalDetail.changeId)) : [];
102
+ const selectedDetailTask = detailTasks[detailSelection] ?? null;
103
+ const detailShortcuts = isDetailActive && detailTasks.length > 0 ? [OPEN_SHORTCUT, COPY_SHORTCUT] : [];
104
+
105
+ React.useEffect(() => {
106
+ if (!copyBanner) {
107
+ return;
108
+ }
109
+
110
+ const timeout = setTimeout(() => {
111
+ setCopyBanner(null);
112
+ }, COPY_BANNER_TIMEOUT_MS);
113
+
114
+ return () => {
115
+ clearTimeout(timeout);
116
+ };
117
+ }, [copyBanner]);
118
+
119
+ React.useEffect(() => {
120
+ const handleResize = () => {
121
+ setDimensions({ columns: stdout.columns, rows: stdout.rows });
122
+ };
123
+
124
+ stdout.on("resize", handleResize);
125
+
126
+ return () => {
127
+ stdout.off("resize", handleResize);
128
+ };
129
+ }, [stdout]);
130
+
131
+ const showTabs = !(mode === "status" && (statusView !== "main" || isPreviewActive || isDetailActive));
132
+ const showHeader = showTabs || Boolean(copyBanner);
133
+ const previewHeight = Math.max(10, dimensions.rows || 0);
134
+
135
+ useInput(
136
+ (_input, key) => {
137
+ if (key.tab) {
138
+ setMode((current) => {
139
+ const currentIndex = tabs.findIndex((tab) => tab.id === current);
140
+ const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % tabs.length;
141
+ return tabs[nextIndex]?.id ?? "status";
142
+ });
143
+ }
144
+ },
145
+ { isActive: showTabs },
146
+ );
147
+
148
+ React.useEffect(() => {
149
+ if (mode !== "status") {
150
+ setStatusShortcuts([]);
151
+ }
152
+ }, [mode]);
153
+
154
+ const handleCopyId = (value: string) => {
155
+ copyToClipboard(value);
156
+ setCopyBanner(COPY_BANNER_TEXT);
157
+ };
158
+
159
+ const handleOpenPreview = (repoRoot: string, relativePath: string) => {
160
+ const targetPath = resolve(repoRoot, relativePath);
161
+ const markdown = readFileSync(targetPath, "utf8");
162
+ setPreview({ fileName: basename(relativePath), markdown });
163
+ };
164
+
165
+ const handleClosePreview = () => {
166
+ setPreview(null);
167
+ };
168
+
169
+ const handleOpenDetail = (changeId: string) => {
170
+ setProposalDetail({ changeId, selection: 0 });
171
+ };
172
+
173
+ const handleCloseDetail = () => {
174
+ setProposalDetail(null);
175
+ };
176
+
177
+ useInput(
178
+ (input, key) => {
179
+ const keyName = (key as { name?: string }).name;
180
+ const keySequence = (key as { sequence?: string }).sequence;
181
+ const isEscape = key.escape || keyName === "escape" || keySequence === "\u001B" || input === "\u001B";
182
+ const isDownArrow = key.downArrow || Boolean(keySequence?.includes("[B")) || input.includes("[B");
183
+ const isUpArrow = key.upArrow || Boolean(keySequence?.includes("[A")) || input.includes("[A");
184
+
185
+ if (isEscape) {
186
+ handleCloseDetail();
187
+ return;
188
+ }
189
+
190
+ if (detailTasks.length === 0) {
191
+ return;
192
+ }
193
+
194
+ if (isDownArrow) {
195
+ setProposalDetail((current) => {
196
+ if (!current) {
197
+ return current;
198
+ }
199
+ const nextSelection = Math.min(current.selection + 1, detailTasks.length - 1);
200
+ if (nextSelection === current.selection) {
201
+ return current;
202
+ }
203
+ return { ...current, selection: nextSelection };
204
+ });
205
+ return;
206
+ }
207
+
208
+ if (isUpArrow) {
209
+ setProposalDetail((current) => {
210
+ if (!current) {
211
+ return current;
212
+ }
213
+ const nextSelection = Math.max(0, current.selection - 1);
214
+ if (nextSelection === current.selection) {
215
+ return current;
216
+ }
217
+ return { ...current, selection: nextSelection };
218
+ });
219
+ return;
220
+ }
221
+
222
+ if (!selectedDetailTask) {
223
+ return;
224
+ }
225
+
226
+ if (input === "o") {
227
+ handleOpenPreview(repoRoot, selectedDetailTask.path);
228
+ return;
229
+ }
230
+
231
+ if (input === "c") {
232
+ handleCopyId(selectedDetailTask.id);
233
+ }
234
+ },
235
+ { isActive: isDetailActive && !isPreviewActive },
236
+ );
237
+
238
+ const shortcuts = mode === "status" && !isPreviewActive ? (isDetailActive ? detailShortcuts : statusShortcuts) : [];
239
+ const isStatusListActive = !isPreviewActive && !isDetailActive;
240
+ const isDetailVisible = isDetailActive && !isPreviewActive;
241
+
242
+ return (
243
+ <Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
244
+ {showHeader ? (
245
+ <Box flexDirection="column" paddingX={2} paddingY={1} flexShrink={0}>
246
+ <Box flexDirection="row" justifyContent={showTabs ? "space-between" : "flex-end"} alignItems="center">
247
+ {showTabs ? (
248
+ <Box flexDirection="row">
249
+ {tabs.map((tab) => {
250
+ const isSelected = mode === tab.id;
251
+ return (
252
+ <Box
253
+ key={tab.id}
254
+ marginRight={4}
255
+ borderStyle="bold"
256
+ borderLeft={isSelected}
257
+ borderTop={false}
258
+ borderRight={false}
259
+ borderBottom={false}
260
+ borderLeftColor={"blueBright"}
261
+ flexDirection="row"
262
+ alignItems="center"
263
+ paddingLeft={1}
264
+ >
265
+ <Text color={isSelected ? "white" : "gray"} bold={isSelected}>
266
+ {tab.label}
267
+ </Text>
268
+ </Box>
269
+ );
270
+ })}
271
+ </Box>
272
+ ) : null}
273
+ {copyBanner ? (
274
+ <Box backgroundColor="green" paddingX={1}>
275
+ <Text color="black">{copyBanner}</Text>
276
+ </Box>
277
+ ) : null}
278
+ </Box>
279
+ </Box>
280
+ ) : null}
281
+ <Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
282
+ {mode === "status" ? (
283
+ <>
284
+ <Box display={isStatusListActive ? "flex" : "none"} flexDirection="column">
285
+ <StatusView
286
+ startDir={resolvedStartDir}
287
+ isActive={isStatusListActive}
288
+ onCopyId={handleCopyId}
289
+ onImplement={onImplement}
290
+ onOpen={handleOpenPreview}
291
+ onOpenDetail={handleOpenDetail}
292
+ onShortcutsChange={setStatusShortcuts}
293
+ onViewChange={setStatusView}
294
+ />
295
+ </Box>
296
+ {isDetailVisible && proposalDetail ? (
297
+ <ProposalDetailView
298
+ changeId={proposalDetail.changeId}
299
+ selection={detailSelection}
300
+ startDir={resolvedStartDir}
301
+ />
302
+ ) : null}
303
+ {isPreviewActive && preview ? (
304
+ <PreviewPage
305
+ fileName={preview.fileName}
306
+ markdown={preview.markdown}
307
+ height={previewHeight}
308
+ onClose={handleClosePreview}
309
+ />
310
+ ) : null}
311
+ </>
312
+ ) : mode === "plan" ? (
313
+ <PlanView startDir={resolvedStartDir} />
314
+ ) : (
315
+ <RoadmapView startDir={resolvedStartDir} />
316
+ )}
317
+ </Box>
318
+ <Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
319
+ <Box justifyContent="space-between">
320
+ <Box flexDirection="row">
321
+ {shortcuts.map((shortcut) => (
322
+ <Box key={shortcut.keyLabel} marginRight={2}>
323
+ <Text color="gray">[</Text>
324
+ <Text color="white">{shortcut.keyLabel}</Text>
325
+ <Text color="gray"> {shortcut.label}]</Text>
326
+ </Box>
327
+ ))}
328
+ </Box>
329
+ {showTabs ? (
330
+ <Box>
331
+ <Text>tab</Text>
332
+ <Text color="gray"> switch mode</Text>
333
+ </Box>
334
+ ) : null}
335
+ </Box>
336
+ <Box justifyContent="space-between" marginTop={1}>
337
+ <Text color="gray">{displaySchubDir}</Text>
338
+ <Text color="gray">{versionLabel}</Text>
339
+ </Box>
340
+ </Box>
341
+ </Box>
342
+ );
343
+ }
@@ -0,0 +1,101 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, renameSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render as inkRender } from "ink-testing-library";
6
+ import PlanView from "./PlanView";
7
+
8
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
9
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
10
+
11
+ const renders: Array<() => void> = [];
12
+ const render = (...args: Parameters<typeof inkRender>) => {
13
+ const rendered = inkRender(...args);
14
+ renders.push(rendered.unmount);
15
+ return rendered;
16
+ };
17
+
18
+ afterEach(() => {
19
+ for (const unmount of renders) {
20
+ unmount();
21
+ }
22
+ renders.length = 0;
23
+ });
24
+
25
+ const extractSection = (output: string, start: string, end?: string): string => {
26
+ const startIndex = output.indexOf(start);
27
+ if (startIndex === -1) {
28
+ return "";
29
+ }
30
+
31
+ const afterStart = output.slice(startIndex + start.length);
32
+ if (!end) {
33
+ return afterStart;
34
+ }
35
+ const endIndex = afterStart.indexOf(end);
36
+ if (endIndex === -1) {
37
+ return afterStart;
38
+ }
39
+
40
+ return afterStart.slice(0, endIndex);
41
+ };
42
+
43
+ test("plan view shows dependency plan without ready section", () => {
44
+ const originalCwd = process.cwd();
45
+ const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
46
+ const tasksDir = join(base, ".schub", "tasks", "backlog");
47
+ mkdirSync(tasksDir, { recursive: true });
48
+ writeFileSync(join(tasksDir, "T0001_ready-task.md"), "# Task: T0001 Ready Task\n", "utf8");
49
+
50
+ try {
51
+ process.chdir(base);
52
+ const { lastFrame } = render(<PlanView />);
53
+ const output = stripAnsi(lastFrame() ?? "");
54
+
55
+ expect(output).toContain("Dependency Plan");
56
+ expect(output).toContain("T0001");
57
+ expect(output).not.toContain("Ready to Implement");
58
+ } finally {
59
+ process.chdir(originalCwd);
60
+ }
61
+ });
62
+
63
+ test("plan view refreshes dependency graph when tasks change", async () => {
64
+ const originalCwd = process.cwd();
65
+ const base = mkdtempSync(join(tmpdir(), "schub-plan-refresh-"));
66
+ const tasksDir = join(base, ".schub", "tasks", "backlog");
67
+ const doneDir = join(base, ".schub", "tasks", "done");
68
+ const refreshIntervalMs = 50;
69
+ let unmount: (() => void) | undefined;
70
+
71
+ mkdirSync(tasksDir, { recursive: true });
72
+ mkdirSync(doneDir, { recursive: true });
73
+
74
+ try {
75
+ process.chdir(base);
76
+ writeFileSync(
77
+ join(tasksDir, "T0002_waiting-task.md"),
78
+ "---\ndepends_on:\n - T0003\n---\n# Task: T0002 Waiting Task\n",
79
+ "utf8",
80
+ );
81
+ writeFileSync(join(tasksDir, "T0003_dependency.md"), "# Task: T0003 Dependency\n", "utf8");
82
+ const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} />);
83
+ unmount = rendered.unmount;
84
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
85
+ const initialGraph = extractSection(initial, "Dependency Plan");
86
+ expect(initialGraph).toContain("T0002");
87
+ expect(initialGraph).toContain("T0003");
88
+
89
+ renameSync(join(tasksDir, "T0003_dependency.md"), join(doneDir, "T0003_dependency.md"));
90
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
91
+
92
+ const refreshed = stripAnsi(rendered.lastFrame() ?? "");
93
+ const refreshedGraph = extractSection(refreshed, "Dependency Plan");
94
+ expect(refreshedGraph).toContain("T0002");
95
+ expect(refreshedGraph).not.toContain("T0003");
96
+ expect(refreshed).not.toContain("Ready to Implement");
97
+ } finally {
98
+ unmount?.();
99
+ process.chdir(originalCwd);
100
+ }
101
+ });
@@ -0,0 +1,89 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import {
4
+ buildTaskGraph,
5
+ findSchubRoot,
6
+ loadTaskDependencies,
7
+ renderTaskGraphLines,
8
+ type TaskStatus,
9
+ } from "../../features/tasks";
10
+
11
+ const PLAN_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready", "wip", "blocked"];
12
+
13
+ type PlanViewProps = {
14
+ refreshIntervalMs?: number;
15
+ startDir?: string;
16
+ };
17
+
18
+ const DEFAULT_REFRESH_INTERVAL_MS = 1000;
19
+
20
+ const buildPlanData = (schubDir: string | null) => {
21
+ if (!schubDir) {
22
+ return { visibleTasks: [], graphLines: [] };
23
+ }
24
+
25
+ const allTasks = loadTaskDependencies(schubDir);
26
+ const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
27
+
28
+ if (visibleTasks.length === 0) {
29
+ return { visibleTasks, graphLines: [] };
30
+ }
31
+
32
+ const graph = buildTaskGraph(visibleTasks);
33
+ const graphLines = renderTaskGraphLines(graph);
34
+ return { visibleTasks, graphLines };
35
+ };
36
+
37
+ export default function PlanView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, startDir }: PlanViewProps) {
38
+ const resolvedStartDir = startDir ?? process.env.SCHUB_CWD ?? process.cwd();
39
+ const schubDir = findSchubRoot(resolvedStartDir);
40
+ const [, setRefreshTick] = React.useState(0);
41
+ const planData = buildPlanData(schubDir);
42
+
43
+ React.useEffect(() => {
44
+ if (!schubDir) {
45
+ return;
46
+ }
47
+
48
+ const interval = setInterval(() => {
49
+ setRefreshTick((current) => current + 1);
50
+ }, refreshIntervalMs);
51
+
52
+ return () => {
53
+ clearInterval(interval);
54
+ };
55
+ }, [refreshIntervalMs, schubDir]);
56
+
57
+ if (!schubDir) {
58
+ return (
59
+ <Box flexDirection="column">
60
+ <Text color="red">No .schub directory found.</Text>
61
+ </Box>
62
+ );
63
+ }
64
+
65
+ if (planData.visibleTasks.length === 0) {
66
+ return (
67
+ <Box flexDirection="column">
68
+ <Text color="gray">No tasks found in .schub</Text>
69
+ </Box>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <Box flexDirection="column">
75
+ <Box flexDirection="column">
76
+ <Box marginBottom={1}>
77
+ <Text bold color="white">
78
+ Dependency Plan
79
+ </Text>
80
+ </Box>
81
+ {planData.graphLines.map((line, index) => (
82
+ <Text key={`${line.text}-${index}`} color={"grey"}>
83
+ {line.text}
84
+ </Text>
85
+ ))}
86
+ </Box>
87
+ </Box>
88
+ );
89
+ }
@@ -0,0 +1,69 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { render as inkRender } from "ink-testing-library";
3
+ import PreviewPage from "./PreviewPage";
4
+
5
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
6
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
7
+
8
+ const renders: Array<() => void> = [];
9
+ const render = (...args: Parameters<typeof inkRender>) => {
10
+ const rendered = inkRender(...args);
11
+ renders.push(rendered.unmount);
12
+ return rendered;
13
+ };
14
+
15
+ afterEach(() => {
16
+ for (const unmount of renders) {
17
+ unmount();
18
+ }
19
+ renders.length = 0;
20
+ });
21
+
22
+ const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0));
23
+ const settleFrames = async () => {
24
+ await nextFrame();
25
+ await nextFrame();
26
+ };
27
+
28
+ test("preview renders header and markdown output", () => {
29
+ const markdown = "# Title\nHello **World**\n```\nconst value = 1\n```";
30
+ const { lastFrame } = render(<PreviewPage fileName="proposal.md" markdown={markdown} height={10} />);
31
+ const output = stripAnsi(lastFrame() ?? "");
32
+
33
+ expect(output).toContain("proposal.md");
34
+ expect(output).toContain("esc to return");
35
+ expect(output).toContain("Title");
36
+ expect(output).toContain("Hello World");
37
+ expect(output).toContain("const value = 1");
38
+ });
39
+
40
+ test("preview scrolls up and down within bounds", async () => {
41
+ const markdown = ["Line 1", "Line 2", "Line 3", "Line 4"].join("\n");
42
+ const rendered = render(<PreviewPage fileName="notes.md" markdown={markdown} height={4} />);
43
+
44
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
45
+ expect(initial).toContain("Line 1");
46
+ expect(initial).toContain("Line 2");
47
+ expect(initial).not.toContain("Line 3");
48
+
49
+ rendered.stdin.write("\u001B[B");
50
+ await settleFrames();
51
+ const scrolled = stripAnsi(rendered.lastFrame() ?? "");
52
+ expect(scrolled).toContain("Line 2");
53
+ expect(scrolled).toContain("Line 3");
54
+ expect(scrolled).not.toContain("Line 1");
55
+
56
+ rendered.stdin.write("\u001B[B");
57
+ rendered.stdin.write("\u001B[B");
58
+ await settleFrames();
59
+ const bottom = stripAnsi(rendered.lastFrame() ?? "");
60
+ expect(bottom).toContain("Line 3");
61
+ expect(bottom).toContain("Line 4");
62
+ expect(bottom).not.toContain("Line 2");
63
+
64
+ rendered.stdin.write("\u001B[A");
65
+ await settleFrames();
66
+ const backUp = stripAnsi(rendered.lastFrame() ?? "");
67
+ expect(backUp).toContain("Line 2");
68
+ expect(backUp).toContain("Line 3");
69
+ });
@@ -0,0 +1,87 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import React from "react";
3
+ import { renderMarkdownLines } from "./markdown-renderer";
4
+
5
+ type PreviewPageProps = {
6
+ fileName: string;
7
+ markdown: string;
8
+ height?: number;
9
+ onClose?: () => void;
10
+ };
11
+
12
+ const HEADER_HEIGHT = 2;
13
+
14
+ export default function PreviewPage({ fileName, markdown, height, onClose }: PreviewPageProps) {
15
+ const { stdout } = useStdout();
16
+ const lines = renderMarkdownLines(markdown);
17
+ const availableHeight = height ?? stdout.rows ?? 0;
18
+ const visibleLineCount = Math.max(0, availableHeight - HEADER_HEIGHT);
19
+ const maxOffset = visibleLineCount > 0 ? Math.max(0, lines.length - visibleLineCount) : 0;
20
+ const [scrollOffset, setScrollOffset] = React.useState(0);
21
+
22
+ const clampOffset = (value: number) => Math.min(maxOffset, Math.max(0, value));
23
+
24
+ React.useEffect(() => {
25
+ setScrollOffset((current) => Math.min(maxOffset, Math.max(0, current)));
26
+ }, [maxOffset]);
27
+
28
+ useInput((input, key) => {
29
+ const keyName = (key as { name?: string }).name;
30
+ const keySequence = (key as { sequence?: string }).sequence;
31
+ const isEscape = key.escape || keyName === "escape" || keySequence === "\u001B" || input === "\u001B";
32
+ const isDownArrow = key.downArrow || Boolean(keySequence?.includes("[B")) || input.includes("[B");
33
+ const isUpArrow = key.upArrow || Boolean(keySequence?.includes("[A")) || input.includes("[A");
34
+
35
+ if (isEscape) {
36
+ onClose?.();
37
+ return;
38
+ }
39
+
40
+ if (isDownArrow) {
41
+ setScrollOffset((current) => clampOffset(current + 1));
42
+ return;
43
+ }
44
+
45
+ if (isUpArrow) {
46
+ setScrollOffset((current) => clampOffset(current - 1));
47
+ }
48
+ });
49
+
50
+ const visibleLines = lines.slice(scrollOffset, scrollOffset + visibleLineCount);
51
+
52
+ return (
53
+ <Box flexDirection="column">
54
+ <Box marginBottom={1} flexDirection="row">
55
+ <Text bold color="white">
56
+ {fileName}
57
+ </Text>
58
+ <Text color="gray"> · esc to return</Text>
59
+ </Box>
60
+ <Box flexDirection="column">
61
+ {visibleLines.map((line, lineIndex) => {
62
+ const lineKey = `${scrollOffset}-${lineIndex}`;
63
+ const hasContent = line.segments.some((segment) => segment.text.length > 0);
64
+
65
+ if (!hasContent) {
66
+ return <Text key={lineKey}> </Text>;
67
+ }
68
+
69
+ return (
70
+ <Text key={lineKey} wrap="wrap">
71
+ {line.segments.map((segment, segmentIndex) => (
72
+ <Text
73
+ key={`${lineKey}-${segmentIndex}`}
74
+ color={segment.color}
75
+ backgroundColor={segment.backgroundColor}
76
+ bold={segment.bold}
77
+ >
78
+ {segment.text}
79
+ </Text>
80
+ ))}
81
+ </Text>
82
+ );
83
+ })}
84
+ </Box>
85
+ </Box>
86
+ );
87
+ }