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.
- package/README.md +27 -0
- package/dist/index.js +12830 -3057
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +5 -1
- package/skills/create-tasks/SKILL.md +5 -4
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +3 -2
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +166 -0
- package/src/changes.ts +159 -54
- package/src/commands/adr.test.ts +6 -5
- package/src/commands/changes.test.ts +136 -14
- package/src/commands/changes.ts +102 -1
- package/src/commands/cookbook.test.ts +6 -5
- package/src/commands/init.test.ts +69 -2
- package/src/commands/init.ts +48 -5
- package/src/commands/review.test.ts +7 -6
- package/src/commands/review.ts +1 -1
- package/src/commands/roadmap.test.ts +84 -0
- package/src/commands/roadmap.ts +84 -0
- package/src/commands/tasks-create.test.ts +22 -22
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- package/src/commands/tasks-list.test.ts +27 -27
- package/src/commands/tasks-update.test.ts +92 -0
- package/src/commands/tasks.ts +98 -1
- package/src/features/roadmap/index.ts +230 -0
- package/src/features/roadmap/roadmap.test.ts +77 -0
- package/src/features/tasks/constants.ts +1 -0
- package/src/features/tasks/create.ts +10 -8
- package/src/features/tasks/filesystem.test.ts +285 -18
- package/src/features/tasks/filesystem.ts +152 -39
- package/src/features/tasks/graph.ts +18 -3
- package/src/features/tasks/index.ts +10 -1
- package/src/features/tasks/worktree.ts +48 -0
- package/src/frontmatter.ts +115 -0
- package/src/index.test.ts +42 -6
- package/src/index.ts +226 -109
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +74 -0
- package/src/tasks.ts +2 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/tui/components/PlanView.test.tsx +101 -0
- package/src/tui/components/PlanView.tsx +89 -0
- package/src/tui/components/PreviewPage.test.tsx +69 -0
- package/src/tui/components/PreviewPage.tsx +87 -0
- package/src/tui/components/ProposalDetailView.test.tsx +169 -0
- package/src/tui/components/ProposalDetailView.tsx +166 -0
- package/src/tui/components/RoadmapView.test.tsx +85 -0
- package/src/tui/components/RoadmapView.tsx +369 -0
- package/src/tui/components/StatusView.test.tsx +1351 -0
- package/src/tui/components/StatusView.tsx +519 -0
- package/src/tui/components/markdown-renderer.test.ts +46 -0
- package/src/tui/components/markdown-renderer.ts +89 -0
- package/src/tui/components/status-view-data.ts +322 -0
- package/src/tui/components/status-view-render.tsx +329 -0
- package/src/tui/index.ts +16 -0
- package/templates/create-proposal/adr-template.md +6 -4
- package/templates/create-proposal/cookbook-template.md +5 -3
- package/templates/create-proposal/proposal-template.md +8 -6
- package/templates/create-roadmap/roadmap.md +5 -0
- package/templates/create-tasks/task-template.md +9 -4
- package/templates/review-proposal/q&a-template.md +8 -3
- package/templates/review-proposal/review-me-template.md +6 -4
- package/templates/setup-project/project-overview-template.md +5 -0
- package/templates/setup-project/project-setup-template.md +5 -0
- package/templates/setup-project/project-wow-template.md +5 -0
- package/src/App.test.tsx +0 -93
- package/src/App.tsx +0 -155
- package/src/components/PlanView.test.tsx +0 -113
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -380
- package/src/components/StatusView.tsx +0 -367
- package/src/ide.ts +0 -7
- package/templates/templates-parity.test.ts +0 -45
- /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
- /package/src/{components → tui/components}/statusColor.ts +0 -0
- /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
- /package/src/{terminal.ts → tui/terminal.ts} +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
---
|
|
2
|
+
change_id: "{{CHANGE_ID}}"
|
|
3
|
+
created: "{{DATE}}"
|
|
4
|
+
status: Draft
|
|
5
|
+
input: "{{INPUT}}"
|
|
6
|
+
---
|
|
2
7
|
|
|
3
|
-
|
|
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]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1
|
+
---
|
|
2
|
+
change_id: "{{CHANGE_ID}}"
|
|
3
|
+
created: "{{DATE}}"
|
|
4
|
+
---
|
|
2
5
|
|
|
3
|
-
|
|
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
|
-
|
|
1
|
+
---
|
|
2
|
+
change_id: "{{CHANGE_ID}}"
|
|
3
|
+
created: "{{DATE}}"
|
|
4
|
+
---
|
|
2
5
|
|
|
3
|
-
|
|
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
|
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
|
-
}
|