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
@@ -1,9 +1,11 @@
1
- # Proposal - {{CHANGE_TITLE}}
1
+ ---
2
+ change_id: "{{CHANGE_ID}}"
3
+ created: "{{DATE}}"
4
+ status: Draft
5
+ input: "{{INPUT}}"
6
+ ---
2
7
 
3
- **Change ID**: `{{CHANGE_ID}}`
4
- **Created**: {{DATE}}
5
- **Status**: Draft
6
- **Input**: {{INPUT}}
8
+ # Proposal - {{CHANGE_TITLE}}
7
9
 
8
10
  ## Summary
9
11
 
@@ -48,4 +50,4 @@
48
50
 
49
51
  ## Potential Issues
50
52
 
51
- - [List potential issues, conflicts, discrepancies, risks]
53
+ - [List potential issues, conflicts, discrepancies, risks]
@@ -0,0 +1,5 @@
1
+ ## Roadmap
2
+
3
+ - **<PROPOSAL_REF>**: **As a** [user type], **I want** [goal], **so that** [value].
4
+ - **<PROPOSAL_REF>**: **As a** [user type], **I want** [goal], **so that** [value].
5
+ - **<PROPOSAL_REF>**: **As a** [user type], **I want** [goal], **so that** [value].
@@ -1,9 +1,14 @@
1
+ ---
2
+ change_id: "{{CHANGE_ID}}"
3
+ priority: "[P1|P2|P3]"
4
+ depends_on: []
5
+ parallelizable: "[no|yes]"
6
+ blocked_reason: ""
7
+ ---
8
+
1
9
  # Task: {{TASK_ID}} {{TASK_TITLE}}
2
10
 
3
- **Change ID**: [`{{CHANGE_ID}}`](../../changes/{{CHANGE_ID}}/proposal.md)
4
- **Priority**: P1
5
- **Depends on**: [optional]
6
- **Parallelizable**: [yes/no]
11
+ Note: When a task is blocked, set `blocked_reason` in the frontmatter.
7
12
 
8
13
  ## Goal
9
14
 
@@ -1,15 +1,20 @@
1
- # Q&A {{CHANGE_TITLE}}
1
+ ---
2
+ change_id: "{{CHANGE_ID}}"
3
+ created: "{{DATE}}"
4
+ ---
2
5
 
3
- **Change ID**: `{{CHANGE_ID}}`
4
- **Created**: {{DATE}}
6
+ # Q&A {{CHANGE_TITLE}}
5
7
 
6
8
  ### ❓ <Question 1>
9
+
7
10
  **Answer:** <answer>
8
11
 
9
12
  ### ❓ <Question 2>
13
+
10
14
  **Answer:** <answer>
11
15
 
12
16
  ### ❓ <Question 3>
17
+
13
18
  **Answer:** <answer>
14
19
 
15
20
  #--- TODO: copy into Q&A Template --
@@ -1,7 +1,9 @@
1
- # REVIEW_ME: {{CHANGE_TITLE}}
1
+ ---
2
+ change_id: "{{CHANGE_ID}}"
3
+ created: "{{DATE}}"
4
+ ---
2
5
 
3
- **Change ID**: `{{CHANGE_ID}}`
4
- **Created**: {{DATE}}
6
+ # REVIEW_ME: {{CHANGE_TITLE}}
5
7
 
6
8
  **Purpose**: Open questions to review requirements with the user.
7
9
 
@@ -15,4 +17,4 @@
15
17
  - Check items off as completed: `[x]`
16
18
  - Add comments or findings inline
17
19
  - Link to relevant resources or documentation
18
- - Items are numbered sequentially for easy reference
20
+ - Items are numbered sequentially for easy reference
@@ -1,3 +1,8 @@
1
+ ---
2
+ title: "[Project Name] - Overview"
3
+ project_name: "[Project Name]"
4
+ ---
5
+
1
6
  # [Project Name] - Overview
2
7
 
3
8
  > **Why this project exists**
@@ -1,3 +1,8 @@
1
+ ---
2
+ title: "[Project Name] - Setup"
3
+ project_name: "[Project Name]"
4
+ ---
5
+
1
6
  # [Project Name] - Setup
2
7
 
3
8
  > **How this project is organized**
@@ -1,3 +1,8 @@
1
+ ---
2
+ title: "[Project Name] — Ways of Working"
3
+ project_name: "[Project Name]"
4
+ ---
5
+
1
6
  # [Project Name] — Ways of Working
2
7
 
3
8
  > **How to write and verify code here**
package/src/App.test.tsx DELETED
@@ -1,93 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
- import { homedir, tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { render } from "ink-testing-library";
6
- import packageJson from "../package.json";
7
- import App from "./App";
8
- import { findSchubRoot } from "./features/tasks";
9
-
10
- const schubDir = findSchubRoot(process.env.SCHUB_CWD ?? process.cwd())!;
11
- const homeDir = homedir();
12
- const displaySchubDir =
13
- schubDir === homeDir ? "~" : schubDir.startsWith(`${homeDir}/`) ? `~${schubDir.slice(homeDir.length)}` : schubDir;
14
-
15
- const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
16
- const stripAnsi = (value: string) => value.replace(ansiPattern, "");
17
-
18
- test("renders tabs with header and footer details", () => {
19
- const { lastFrame } = render(<App />);
20
- const output = stripAnsi(lastFrame() || "");
21
- expect(output).toContain("Status");
22
- expect(output).toContain("Plan");
23
- expect(output).toContain("switch mode");
24
- expect(output).toContain("[o open file]");
25
- expect(output).toContain("[c copy]");
26
- expect(output).toContain(displaySchubDir);
27
- expect(output).toContain(packageJson.version);
28
- });
29
-
30
- test("selected tab shows a blue left indicator", () => {
31
- const originalForceColor = process.env.FORCE_COLOR;
32
- process.env.FORCE_COLOR = "1";
33
-
34
- try {
35
- const { lastFrame } = render(<App />);
36
- const output = lastFrame() || "";
37
- const statusLine = output.split("\n").find((line) => line.includes("Status")) ?? "";
38
- expect(statusLine).toContain("Status");
39
- expect(statusLine).not.toContain("│");
40
- } finally {
41
- if (originalForceColor === undefined) {
42
- delete process.env.FORCE_COLOR;
43
- } else {
44
- process.env.FORCE_COLOR = originalForceColor;
45
- }
46
- }
47
- });
48
-
49
- test("shows a copy banner after copying an item", async () => {
50
- const originalCwd = process.cwd();
51
- const baseDir = mkdtempSync(join(tmpdir(), "schub-app-copy-"));
52
- const readyRoot = join(baseDir, ".schub", "tasks", "ready");
53
- let copied = "";
54
- const recordCopy = (value: string) => {
55
- copied = value;
56
- };
57
-
58
- mkdirSync(readyRoot, { recursive: true });
59
- writeFileSync(join(readyRoot, "T900_copy-task.md"), "# Task: T900 Copy Task\n", "utf8");
60
-
61
- try {
62
- process.chdir(baseDir);
63
- const rendered = render(<App copyToClipboard={recordCopy} />);
64
- rendered.stdin.write("c");
65
- await new Promise((resolve) => setTimeout(resolve, 0));
66
- const output = rendered.lastFrame() ?? "";
67
- expect(copied).toBe("T900");
68
- expect(output).toContain("Copied to clipboard !");
69
- } finally {
70
- process.chdir(originalCwd);
71
- }
72
- });
73
-
74
- test("renders when no .schub directory is found", () => {
75
- const originalCwd = process.cwd();
76
- const originalSchubCwd = process.env.SCHUB_CWD;
77
- const baseDir = mkdtempSync(join(tmpdir(), "schub-app-"));
78
- process.env.SCHUB_CWD = baseDir;
79
-
80
- try {
81
- process.chdir(baseDir);
82
- const { lastFrame } = render(<App />);
83
- const output = lastFrame() ?? "";
84
- expect(output).toContain("No .schub directory found.");
85
- } finally {
86
- process.chdir(originalCwd);
87
- if (originalSchubCwd === undefined) {
88
- delete process.env.SCHUB_CWD;
89
- } else {
90
- process.env.SCHUB_CWD = originalSchubCwd;
91
- }
92
- }
93
- });
package/src/App.tsx DELETED
@@ -1,155 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { Box, Text, useInput, useStdout } from "ink";
3
- import React from "react";
4
- import packageJson from "../package.json";
5
- import { copyToClipboard as copyToClipboardDefault } from "./clipboard";
6
- import PlanView from "./components/PlanView";
7
- import StatusView from "./components/StatusView";
8
- import { findSchubRoot } from "./features/tasks";
9
-
10
- type Mode = "status" | "plan";
11
-
12
- type TabDefinition = {
13
- id: Mode;
14
- label: string;
15
- };
16
-
17
- const tabs: TabDefinition[] = [
18
- { id: "status", label: "Status" },
19
- { id: "plan", label: "Plan" },
20
- ];
21
-
22
- type AppProps = {
23
- copyToClipboard?: (value: string) => void;
24
- };
25
-
26
- const COPY_BANNER_TEXT = "Copied to clipboard !";
27
- const COPY_BANNER_TIMEOUT_MS = 1500;
28
-
29
- export default function App({ copyToClipboard = copyToClipboardDefault }: AppProps) {
30
- const [mode, setMode] = React.useState<Mode>("status");
31
- const { stdout } = useStdout();
32
- const [dimensions, setDimensions] = React.useState(() => ({
33
- columns: stdout.columns,
34
- rows: stdout.rows,
35
- }));
36
- const [copyBanner, setCopyBanner] = React.useState<string | null>(null);
37
- const versionLabel = `${packageJson.version}`;
38
- const homeDir = homedir();
39
- const startDir = process.env.SCHUB_CWD ?? process.cwd();
40
- const schubDir = findSchubRoot(startDir);
41
- const displaySchubDir = (() => {
42
- const targetDir = schubDir ?? startDir;
43
- if (targetDir === homeDir) {
44
- return "~";
45
- }
46
- if (targetDir.startsWith(`${homeDir}/`)) {
47
- return `~${targetDir.slice(homeDir.length)}`;
48
- }
49
- return targetDir;
50
- })();
51
-
52
- React.useEffect(() => {
53
- if (!copyBanner) {
54
- return;
55
- }
56
-
57
- const timeout = setTimeout(() => {
58
- setCopyBanner(null);
59
- }, COPY_BANNER_TIMEOUT_MS);
60
-
61
- return () => {
62
- clearTimeout(timeout);
63
- };
64
- }, [copyBanner]);
65
-
66
- React.useEffect(() => {
67
- const handleResize = () => {
68
- setDimensions({ columns: stdout.columns, rows: stdout.rows });
69
- };
70
-
71
- stdout.on("resize", handleResize);
72
-
73
- return () => {
74
- stdout.off("resize", handleResize);
75
- };
76
- }, [stdout]);
77
-
78
- useInput((_input, key) => {
79
- if (key.tab) {
80
- setMode((current) => (current === "status" ? "plan" : "status"));
81
- }
82
- });
83
-
84
- const handleCopyId = (value: string) => {
85
- copyToClipboard(value);
86
- setCopyBanner(COPY_BANNER_TEXT);
87
- };
88
-
89
- const shortcuts = [
90
- { keyLabel: "o", label: "open file" },
91
- { keyLabel: "c", label: "copy" },
92
- ];
93
-
94
- return (
95
- <Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
96
- <Box flexDirection="column" paddingX={2} paddingY={1} flexShrink={0}>
97
- <Box flexDirection="row" justifyContent="space-between" alignItems="center">
98
- <Box flexDirection="row">
99
- {tabs.map((tab) => {
100
- const isSelected = mode === tab.id;
101
- return (
102
- <Box
103
- key={tab.id}
104
- marginRight={4}
105
- borderStyle="bold"
106
- borderLeft={isSelected}
107
- borderTop={false}
108
- borderRight={false}
109
- borderBottom={false}
110
- borderLeftColor={"blueBright"}
111
- flexDirection="row"
112
- alignItems="center"
113
- paddingLeft={1}
114
- >
115
- <Text color={isSelected ? "white" : "gray"} bold={isSelected}>
116
- {tab.label}
117
- </Text>
118
- </Box>
119
- );
120
- })}
121
- </Box>
122
- {copyBanner ? (
123
- <Box backgroundColor="green" paddingX={1}>
124
- <Text color="black">{copyBanner}</Text>
125
- </Box>
126
- ) : null}
127
- </Box>
128
- </Box>
129
- <Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
130
- {mode === "status" ? <StatusView onCopyId={handleCopyId} /> : <PlanView onCopyId={handleCopyId} />}
131
- </Box>
132
- <Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
133
- <Box justifyContent="space-between">
134
- <Box flexDirection="row">
135
- {shortcuts.map((shortcut) => (
136
- <Box key={shortcut.keyLabel} marginRight={2}>
137
- <Text color="gray">[</Text>
138
- <Text color="white">{shortcut.keyLabel}</Text>
139
- <Text color="gray"> {shortcut.label}]</Text>
140
- </Box>
141
- ))}
142
- </Box>
143
- <Box marginRight={2}>
144
- <Text>tab</Text>
145
- <Text color="gray"> switch mode</Text>
146
- </Box>
147
- </Box>
148
- <Box justifyContent="space-between" marginTop={1}>
149
- <Text color="gray">{displaySchubDir}</Text>
150
- <Text color="gray">{versionLabel}</Text>
151
- </Box>
152
- </Box>
153
- </Box>
154
- );
155
- }
@@ -1,113 +0,0 @@
1
- import { 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 } 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 extractSection = (output: string, start: string, end?: string): string => {
12
- const startIndex = output.indexOf(start);
13
- if (startIndex === -1) {
14
- return "";
15
- }
16
-
17
- const afterStart = output.slice(startIndex + start.length);
18
- if (!end) {
19
- return afterStart;
20
- }
21
- const endIndex = afterStart.indexOf(end);
22
- if (endIndex === -1) {
23
- return afterStart;
24
- }
25
-
26
- return afterStart.slice(0, endIndex);
27
- };
28
-
29
- const noopCopy = () => {};
30
-
31
- test("plan shows backlog tasks with satisfied dependencies as ready", () => {
32
- const originalCwd = process.cwd();
33
- const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
34
- const tasksDir = join(base, ".schub", "tasks", "backlog");
35
- mkdirSync(tasksDir, { recursive: true });
36
- writeFileSync(join(tasksDir, "T001_ready-task.md"), "# Task: T001 Ready Task\n", "utf8");
37
-
38
- try {
39
- process.chdir(base);
40
- const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
41
- const output = stripAnsi(lastFrame() ?? "");
42
- const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
43
-
44
- expect(readySection).toContain("T001");
45
- expect(readySection).not.toContain("No tasks ready for implementation.");
46
- } finally {
47
- process.chdir(originalCwd);
48
- }
49
- });
50
-
51
- test("plan view omits per-item shortcuts for ready tasks", () => {
52
- const originalCwd = process.cwd();
53
- const base = mkdtempSync(join(tmpdir(), "schub-plan-actions-"));
54
- const tasksDir = join(base, ".schub", "tasks", "backlog");
55
- mkdirSync(tasksDir, { recursive: true });
56
- writeFileSync(join(tasksDir, "T010_quick-action.md"), "# Task: T010 Quick Action\n", "utf8");
57
-
58
- try {
59
- process.chdir(base);
60
- const { lastFrame } = render(<PlanView onCopyId={noopCopy} />);
61
- const output = stripAnsi(lastFrame() ?? "");
62
- const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
63
-
64
- expect(readySection).toContain("T010");
65
- expect(readySection).not.toContain("[o open file]");
66
- expect(readySection).not.toContain("[c copy]");
67
- } finally {
68
- process.chdir(originalCwd);
69
- }
70
- });
71
-
72
- test("plan view refreshes ready list and dependency graph when tasks change", async () => {
73
- const originalCwd = process.cwd();
74
- const base = mkdtempSync(join(tmpdir(), "schub-plan-refresh-"));
75
- const tasksDir = join(base, ".schub", "tasks", "backlog");
76
- const doneDir = join(base, ".schub", "tasks", "done");
77
- const refreshIntervalMs = 50;
78
- let unmount: (() => void) | undefined;
79
-
80
- mkdirSync(tasksDir, { recursive: true });
81
- mkdirSync(doneDir, { recursive: true });
82
-
83
- try {
84
- process.chdir(base);
85
- writeFileSync(
86
- join(tasksDir, "T002_waiting-task.md"),
87
- "# Task: T002 Waiting Task\n\n**Depends on**: T003\n",
88
- "utf8",
89
- );
90
- writeFileSync(join(tasksDir, "T003_dependency.md"), "# Task: T003 Dependency\n", "utf8");
91
- const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
92
- unmount = rendered.unmount;
93
- const initial = stripAnsi(rendered.lastFrame() ?? "");
94
- const initialReady = extractSection(initial, "Ready to Implement", "Dependency Plan");
95
- expect(initialReady).not.toContain("T002");
96
- const initialGraph = extractSection(initial, "Dependency Plan");
97
- expect(initialGraph).toContain("T002");
98
- expect(initialGraph).toContain("T003");
99
-
100
- renameSync(join(tasksDir, "T003_dependency.md"), join(doneDir, "T003_dependency.md"));
101
- await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
102
-
103
- const refreshed = stripAnsi(rendered.lastFrame() ?? "");
104
- const refreshedReady = extractSection(refreshed, "Ready to Implement", "Dependency Plan");
105
- expect(refreshedReady).toContain("T002");
106
- const refreshedGraph = extractSection(refreshed, "Dependency Plan");
107
- expect(refreshedGraph).toContain("T002");
108
- expect(refreshedGraph).not.toContain("T003");
109
- } finally {
110
- unmount?.();
111
- process.chdir(originalCwd);
112
- }
113
- });
@@ -1,160 +0,0 @@
1
- import { dirname } from "node:path";
2
- import { Box, Text, useInput } from "ink";
3
- import React from "react";
4
- import {
5
- buildTaskGraph,
6
- findSchubRoot,
7
- loadTaskDependencies,
8
- renderTaskGraphLines,
9
- type TaskStatus,
10
- trimTaskTitle,
11
- } from "../features/tasks";
12
- import { openInVsCode } from "../ide";
13
-
14
- const PLAN_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready", "wip", "blocked"];
15
- const READY_TASK_STATUSES: readonly TaskStatus[] = ["backlog", "ready"];
16
-
17
- type PlanViewProps = {
18
- refreshIntervalMs?: number;
19
- onCopyId: (id: string) => void;
20
- };
21
-
22
- const DEFAULT_REFRESH_INTERVAL_MS = 1000;
23
-
24
- const buildPlanData = (schubDir: string | null) => {
25
- if (!schubDir) {
26
- return { visibleTasks: [], readyTasks: [], graphLines: [] };
27
- }
28
-
29
- const allTasks = loadTaskDependencies(schubDir);
30
- const visibleTasks = allTasks.filter((task) => PLAN_TASK_STATUSES.includes(task.status));
31
- const tasksById = new Map(allTasks.map((task) => [task.id, task]));
32
- const readyTasks = visibleTasks.filter((task) => {
33
- if (!READY_TASK_STATUSES.includes(task.status)) {
34
- return false;
35
- }
36
-
37
- return task.dependsOn.every((dependencyId) => tasksById.get(dependencyId)?.status === "done");
38
- });
39
-
40
- if (visibleTasks.length === 0) {
41
- return { visibleTasks, readyTasks, graphLines: [] };
42
- }
43
-
44
- const graph = buildTaskGraph(visibleTasks);
45
- const graphLines = renderTaskGraphLines(graph);
46
- return { visibleTasks, readyTasks, graphLines };
47
- };
48
-
49
- export default function PlanView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: PlanViewProps) {
50
- const schubDir = findSchubRoot();
51
- const [, setRefreshTick] = React.useState(0);
52
- const planData = buildPlanData(schubDir);
53
- const [selection, setSelection] = React.useState(0);
54
- const totalReadyTasks = planData.readyTasks.length;
55
- const repoRoot = schubDir ? dirname(schubDir) : "";
56
-
57
- React.useEffect(() => {
58
- if (!schubDir) {
59
- return;
60
- }
61
-
62
- const interval = setInterval(() => {
63
- setRefreshTick((current) => current + 1);
64
- }, refreshIntervalMs);
65
-
66
- return () => {
67
- clearInterval(interval);
68
- };
69
- }, [refreshIntervalMs, schubDir]);
70
-
71
- React.useEffect(() => {
72
- if (totalReadyTasks === 0) {
73
- setSelection(0);
74
- return;
75
- }
76
- setSelection((current) => Math.min(current, totalReadyTasks - 1));
77
- }, [totalReadyTasks]);
78
-
79
- useInput((input, key) => {
80
- if (totalReadyTasks === 0) {
81
- return;
82
- }
83
-
84
- if (key.downArrow) {
85
- setSelection((current) => Math.min(current + 1, totalReadyTasks - 1));
86
- }
87
-
88
- if (key.upArrow) {
89
- setSelection((current) => Math.max(current - 1, 0));
90
- }
91
-
92
- if (input === "o") {
93
- const selectedTask = planData.readyTasks[selection];
94
- openInVsCode(repoRoot, selectedTask.path);
95
- }
96
-
97
- if (input === "c") {
98
- const selectedTask = planData.readyTasks[selection];
99
- onCopyId(selectedTask.id);
100
- }
101
- });
102
-
103
- if (!schubDir) {
104
- return (
105
- <Box flexDirection="column">
106
- <Text color="red">No .schub directory found.</Text>
107
- </Box>
108
- );
109
- }
110
-
111
- if (planData.visibleTasks.length === 0) {
112
- return (
113
- <Box flexDirection="column">
114
- <Text color="gray">No tasks found in .schub</Text>
115
- </Box>
116
- );
117
- }
118
-
119
- return (
120
- <Box flexDirection="column">
121
- <Box flexDirection="column">
122
- <Box marginBottom={1}>
123
- <Text bold color="white">
124
- Ready to Implement
125
- </Text>
126
- </Box>
127
- {planData.readyTasks.length === 0 ? (
128
- <Text color="gray">No tasks ready for implementation.</Text>
129
- ) : (
130
- planData.readyTasks.map((task, index) => {
131
- const selected = index === selection;
132
- return (
133
- <Box key={task.id} marginLeft={1}>
134
- <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
135
- <Box marginLeft={1}>
136
- <Text color="white" bold={selected}>
137
- {task.id}
138
- </Text>
139
- <Text color="gray"> {trimTaskTitle(task.title)}</Text>
140
- </Box>
141
- </Box>
142
- );
143
- })
144
- )}
145
- </Box>
146
- <Box flexDirection="column" marginTop={2}>
147
- <Box marginBottom={1}>
148
- <Text bold color="white">
149
- Dependency Plan
150
- </Text>
151
- </Box>
152
- {planData.graphLines.map((line, index) => (
153
- <Text key={`${line.text}-${index}`} color={"grey"}>
154
- {line.text}
155
- </Text>
156
- ))}
157
- </Box>
158
- </Box>
159
- );
160
- }