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,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+
4
+ type OpencodeSpawnOptions = {
5
+ stdio: "ignore";
6
+ detached: true;
7
+ env?: NodeJS.ProcessEnv;
8
+ cwd?: string;
9
+ };
10
+
11
+ type OpencodeSpawnOverrides = Partial<OpencodeSpawnOptions>;
12
+
13
+ type OpencodeSpawner = (
14
+ command: string,
15
+ args: readonly string[],
16
+ options: OpencodeSpawnOptions,
17
+ ) => { unref: () => void };
18
+
19
+ const opencodeSpawnOptions: OpencodeSpawnOptions = { stdio: "ignore", detached: true };
20
+
21
+ const spawnOpencode = (
22
+ command: string,
23
+ args: readonly string[],
24
+ spawner: OpencodeSpawner,
25
+ overrides?: OpencodeSpawnOverrides,
26
+ ) => {
27
+ spawner(command, args, { ...opencodeSpawnOptions, ...overrides }).unref();
28
+ };
29
+
30
+ const buildSessionTitle = (repoRoot: string, label: string, id: string, detail?: string) => {
31
+ const trimmedDetail = detail?.trim();
32
+ const suffix = trimmedDetail ? ` ${trimmedDetail}` : "";
33
+
34
+ return `(${basename(repoRoot)}) ${label} ${id}${suffix}`;
35
+ };
36
+
37
+ export const buildReviewCommand = (changeId: string, repoRoot: string, changeTitle?: string) => {
38
+ const title = buildSessionTitle(repoRoot, "Review", changeId, changeTitle);
39
+
40
+ return {
41
+ command: "opencode",
42
+ args: ["run", "review", changeId, "--title", title],
43
+ };
44
+ };
45
+
46
+ export const buildImplementCommand = (taskId: string, repoRoot: string, taskTitle?: string) => {
47
+ const title = buildSessionTitle(repoRoot, "Implement", taskId, taskTitle);
48
+
49
+ return {
50
+ command: "opencode",
51
+ args: ["run", "implement", taskId, "--title", title],
52
+ };
53
+ };
54
+
55
+ export const launchOpencodeReview = (
56
+ changeId: string,
57
+ repoRoot: string,
58
+ overrides?: OpencodeSpawnOverrides,
59
+ spawner: OpencodeSpawner = spawn,
60
+ ) => {
61
+ const { command, args } = buildReviewCommand(changeId, repoRoot);
62
+ spawnOpencode(command, args, spawner, overrides);
63
+ };
64
+
65
+ export const launchOpencodeImplement = (
66
+ taskId: string,
67
+ repoRoot: string,
68
+ taskTitle?: string,
69
+ overrides?: OpencodeSpawnOverrides,
70
+ spawner: OpencodeSpawner = spawn,
71
+ ) => {
72
+ const { command, args } = buildImplementCommand(taskId, repoRoot, taskTitle);
73
+ spawnOpencode(command, args, spawner, overrides);
74
+ };
package/src/tasks.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {
2
+ archiveTasksForChange,
2
3
  buildTaskGraph,
3
4
  findSchubRoot,
4
5
  listTasks,
@@ -12,4 +13,5 @@ export {
12
13
  type TaskInfo,
13
14
  type TaskStatus,
14
15
  trimTaskTitle,
16
+ updateTaskStatuses,
15
17
  } from "./features/tasks";
@@ -0,0 +1,418 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render as inkRender } from "ink-testing-library";
6
+ import packageJson from "../../package.json";
7
+ import { findSchubRoot } from "../features/tasks";
8
+ import App from "./App";
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
+ const settleFrames = async () => {
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+ await new Promise((resolve) => setTimeout(resolve, 0));
20
+ };
21
+
22
+ const renders: Array<() => void> = [];
23
+ const render = (...args: Parameters<typeof inkRender>) => {
24
+ const rendered = inkRender(...args);
25
+ renders.push(rendered.unmount);
26
+ return rendered;
27
+ };
28
+
29
+ afterEach(() => {
30
+ for (const unmount of renders) {
31
+ unmount();
32
+ }
33
+ renders.length = 0;
34
+ });
35
+
36
+ const writeProposal = (changesRoot: string, changeId: string, status: string, title = changeId) => {
37
+ const changeDir = join(changesRoot, changeId);
38
+ mkdirSync(changeDir, { recursive: true });
39
+ writeFileSync(join(changeDir, "proposal.md"), `---\nstatus: ${status}\n---\n# Proposal - ${title}\n`, "utf8");
40
+ };
41
+
42
+ const writeDetailedProposal = (changesRoot: string, changeId: string, status: string, summary: string) => {
43
+ const changeDir = join(changesRoot, changeId);
44
+ mkdirSync(changeDir, { recursive: true });
45
+ writeFileSync(
46
+ join(changeDir, "proposal.md"),
47
+ `---\nchange_id: ${changeId}\ncreated: 2026-01-21\nstatus: ${status}\ninput: Detail view\n---\n# Proposal - ${changeId}\n\n## Summary\n\n${summary}\n`,
48
+ "utf8",
49
+ );
50
+ };
51
+
52
+ const writeTask = (tasksRoot: string, id: string, slug: string, body: string) => {
53
+ const filePath = join(tasksRoot, `${id}_${slug}.md`);
54
+ writeFileSync(filePath, body, "utf8");
55
+ };
56
+ test("renders tabs with header and footer details", () => {
57
+ const { lastFrame } = render(<App />);
58
+ const output = stripAnsi(lastFrame() || "");
59
+ expect(output).toContain("Status");
60
+ expect(output).toContain("Plan");
61
+ expect(output).toContain("Roadmap");
62
+ expect(output).toContain("switch mode");
63
+ expect(output).toContain(displaySchubDir);
64
+ expect(output).toContain(packageJson.version);
65
+ });
66
+ test("shows open and copy shortcuts only when selection is openable", async () => {
67
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-open-shortcuts-"));
68
+ const changesRoot = join(baseDir, ".schub", "changes");
69
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
70
+
71
+ mkdirSync(changesRoot, { recursive: true });
72
+ mkdirSync(readyRoot, { recursive: true });
73
+
74
+ writeProposal(changesRoot, "C0102_openable", "Draft", "Openable Proposal");
75
+ writeTask(readyRoot, "T0102", "ready-task", "# Task: T0102 Ready Task\n");
76
+
77
+ const rendered = render(<App startDir={baseDir} />);
78
+ await new Promise((resolve) => setTimeout(resolve, 0));
79
+ await new Promise((resolve) => setTimeout(resolve, 0));
80
+
81
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
82
+ expect(initial).toContain("[o preview]");
83
+ expect(initial).toContain("[c copy id]");
84
+
85
+ rendered.stdin.write("\u001B[B");
86
+ await new Promise((resolve) => setTimeout(resolve, 0));
87
+ await new Promise((resolve) => setTimeout(resolve, 0));
88
+
89
+ const moved = stripAnsi(rendered.lastFrame() ?? "");
90
+ expect(moved).not.toContain("[o preview]");
91
+ expect(moved).not.toContain("[c copy id]");
92
+ });
93
+ test("opens preview from status view and returns on escape", async () => {
94
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-preview-"));
95
+ const backlogRoot = join(baseDir, ".schub", "tasks", "backlog");
96
+ mkdirSync(backlogRoot, { recursive: true });
97
+ writeTask(backlogRoot, "T0104", "preview-task", "# Task: T0104 Preview Task\n\n## Notes\nHello preview\n");
98
+
99
+ const rendered = render(<App startDir={baseDir} />);
100
+ await settleFrames();
101
+ rendered.stdin.write("o");
102
+ await settleFrames();
103
+
104
+ let output = stripAnsi(rendered.lastFrame() ?? "");
105
+ expect(output).toContain("T0104_preview-task.md");
106
+ expect(output).toContain("Hello preview");
107
+
108
+ rendered.stdin.write("\u001B");
109
+ await settleFrames();
110
+ output = stripAnsi(rendered.lastFrame() ?? "");
111
+ expect(output).toContain("Change Proposals");
112
+ });
113
+ test("keeps show-all tasks active when preview closes", async () => {
114
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-preview-show-all-"));
115
+ const backlogRoot = join(baseDir, ".schub", "tasks", "backlog");
116
+ mkdirSync(backlogRoot, { recursive: true });
117
+ writeTask(backlogRoot, "T0105", "preview-task", "# Task: T0105 Preview Task\n\n## Notes\nHello show all\n");
118
+
119
+ const rendered = render(<App startDir={baseDir} />);
120
+ await settleFrames();
121
+ rendered.stdin.write("t");
122
+ await settleFrames();
123
+ rendered.stdin.write("o");
124
+ await settleFrames();
125
+
126
+ let output = stripAnsi(rendered.lastFrame() ?? "");
127
+ expect(output).toContain("T0105_preview-task.md");
128
+
129
+ rendered.stdin.write("\t");
130
+ await settleFrames();
131
+ rendered.stdin.write("\u001B");
132
+ await settleFrames();
133
+
134
+ output = stripAnsi(rendered.lastFrame() ?? "");
135
+ expect(output).toContain("All Tasks");
136
+ expect(output).not.toContain("Dependency Plan");
137
+ });
138
+
139
+ test("opens proposal detail from status view and returns on escape", async () => {
140
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-detail-view-"));
141
+ const changesRoot = join(baseDir, ".schub", "changes");
142
+ const changeId = "C1400_detail-view";
143
+
144
+ mkdirSync(changesRoot, { recursive: true });
145
+ writeDetailedProposal(changesRoot, changeId, "Draft", "Detail view summary.");
146
+
147
+ const rendered = render(<App startDir={baseDir} />);
148
+ await settleFrames();
149
+
150
+ rendered.stdin.write("\n");
151
+ await settleFrames();
152
+
153
+ let output = stripAnsi(rendered.lastFrame() ?? "");
154
+ expect(output).toContain("Proposal Detail");
155
+
156
+ rendered.stdin.write("\u001B");
157
+ await settleFrames();
158
+
159
+ output = stripAnsi(rendered.lastFrame() ?? "");
160
+ expect(output).toContain("Change Proposals");
161
+ });
162
+
163
+ test("detail view exposes open and copy shortcuts for tasks", async () => {
164
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-detail-shortcuts-"));
165
+ const changesRoot = join(baseDir, ".schub", "changes");
166
+ const backlogRoot = join(baseDir, ".schub", "tasks", "backlog");
167
+ const changeId = "C1401_detail-actions";
168
+ let copied = "";
169
+
170
+ mkdirSync(changesRoot, { recursive: true });
171
+ mkdirSync(backlogRoot, { recursive: true });
172
+
173
+ writeDetailedProposal(changesRoot, changeId, "Draft", "Detail action summary.");
174
+ writeTask(
175
+ backlogRoot,
176
+ "T1400",
177
+ "detail-task",
178
+ `---\nchange_id: ${changeId}\n---\n# Task: T1400 Detail Task\n\n## Notes\n\nHello detail\n`,
179
+ );
180
+
181
+ const rendered = render(
182
+ <App
183
+ startDir={baseDir}
184
+ copyToClipboard={(value) => {
185
+ copied = value;
186
+ }}
187
+ />,
188
+ );
189
+
190
+ await settleFrames();
191
+ rendered.stdin.write("\n");
192
+ await settleFrames();
193
+
194
+ const detailOutput = stripAnsi(rendered.lastFrame() ?? "");
195
+ expect(detailOutput).toContain("[o preview]");
196
+ expect(detailOutput).toContain("[c copy id]");
197
+
198
+ rendered.stdin.write("c");
199
+ await settleFrames();
200
+ expect(copied).toBe("T1400");
201
+
202
+ rendered.stdin.write("o");
203
+ await settleFrames();
204
+
205
+ const previewOutput = stripAnsi(rendered.lastFrame() ?? "");
206
+ expect(previewOutput).toContain("T1400_detail-task.md");
207
+ expect(previewOutput).toContain("Hello detail");
208
+ });
209
+ test("hides tabs in show-all proposals view", async () => {
210
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-show-all-"));
211
+ const changesRoot = join(baseDir, ".schub", "changes");
212
+
213
+ mkdirSync(changesRoot, { recursive: true });
214
+ writeProposal(changesRoot, "C0100_show-all", "Draft", "Show All Proposal");
215
+
216
+ const rendered = render(<App startDir={baseDir} />);
217
+ await new Promise((resolve) => setTimeout(resolve, 0));
218
+ await new Promise((resolve) => setTimeout(resolve, 0));
219
+
220
+ rendered.stdin.write("p");
221
+ await new Promise((resolve) => setTimeout(resolve, 0));
222
+ await new Promise((resolve) => setTimeout(resolve, 0));
223
+
224
+ const output = stripAnsi(rendered.lastFrame() ?? "");
225
+ expect(output).toContain("All Proposals");
226
+ expect(output).not.toContain("Status");
227
+ expect(output).not.toContain("Plan");
228
+ expect(output).not.toContain("switch mode");
229
+ });
230
+ test("hides tabs in show-all tasks view", async () => {
231
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-show-all-tasks-"));
232
+ const backlogRoot = join(baseDir, ".schub", "tasks", "backlog");
233
+
234
+ mkdirSync(backlogRoot, { recursive: true });
235
+ writeTask(backlogRoot, "T0100", "task", "# Task: T0100 Task\n");
236
+
237
+ const rendered = render(<App startDir={baseDir} />);
238
+ await new Promise((resolve) => setTimeout(resolve, 0));
239
+ await new Promise((resolve) => setTimeout(resolve, 0));
240
+
241
+ rendered.stdin.write("t");
242
+ await new Promise((resolve) => setTimeout(resolve, 0));
243
+ await new Promise((resolve) => setTimeout(resolve, 0));
244
+
245
+ const output = stripAnsi(rendered.lastFrame() ?? "");
246
+ expect(output).toContain("All Tasks");
247
+ expect(output).not.toContain("Status");
248
+ expect(output).not.toContain("Plan");
249
+ expect(output).not.toContain("switch mode");
250
+ });
251
+ test("ignores tab while showing all proposals", async () => {
252
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-show-all-tab-"));
253
+ const changesRoot = join(baseDir, ".schub", "changes");
254
+
255
+ mkdirSync(changesRoot, { recursive: true });
256
+ writeProposal(changesRoot, "C0101_show-all", "Draft", "Show All Proposal");
257
+
258
+ const rendered = render(<App startDir={baseDir} />);
259
+ await new Promise((resolve) => setTimeout(resolve, 0));
260
+ await new Promise((resolve) => setTimeout(resolve, 0));
261
+
262
+ rendered.stdin.write("p");
263
+ await new Promise((resolve) => setTimeout(resolve, 0));
264
+ await new Promise((resolve) => setTimeout(resolve, 0));
265
+
266
+ rendered.stdin.write("\t");
267
+ await new Promise((resolve) => setTimeout(resolve, 0));
268
+ await new Promise((resolve) => setTimeout(resolve, 0));
269
+
270
+ const output = stripAnsi(rendered.lastFrame() ?? "");
271
+ expect(output).toContain("All Proposals");
272
+ });
273
+ test("shows implement shortcut when ready to implement task is selected", async () => {
274
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-implement-shortcut-"));
275
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
276
+ const doneRoot = join(baseDir, ".schub", "tasks", "done");
277
+
278
+ mkdirSync(readyRoot, { recursive: true });
279
+ mkdirSync(doneRoot, { recursive: true });
280
+
281
+ writeTask(doneRoot, "T0500", "done-task", "# Task: T0500 Done Task\n");
282
+ writeTask(readyRoot, "T0501", "ready-task", "---\ndepends_on:\n - T0500\n---\n# Task: T0501 Ready Task\n");
283
+
284
+ const rendered = render(<App startDir={baseDir} />);
285
+ await new Promise((resolve) => setTimeout(resolve, 0));
286
+ await new Promise((resolve) => setTimeout(resolve, 0));
287
+ const output = stripAnsi(rendered.lastFrame() ?? "");
288
+ expect(output).toContain("[i implement]");
289
+ });
290
+ test("hides implement shortcut when selection moves off ready to implement", async () => {
291
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-implement-hide-"));
292
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
293
+ const doneRoot = join(baseDir, ".schub", "tasks", "done");
294
+
295
+ mkdirSync(readyRoot, { recursive: true });
296
+ mkdirSync(doneRoot, { recursive: true });
297
+
298
+ writeTask(doneRoot, "T0502", "done-task", "# Task: T0502 Done Task\n");
299
+ writeTask(readyRoot, "T0503", "ready-task", "---\ndepends_on:\n - T0502\n---\n# Task: T0503 Ready Task\n");
300
+
301
+ const rendered = render(<App startDir={baseDir} />);
302
+ await new Promise((resolve) => setTimeout(resolve, 0));
303
+ await new Promise((resolve) => setTimeout(resolve, 0));
304
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
305
+ expect(initial).toContain("[i implement]");
306
+
307
+ rendered.stdin.write("\u001B[B");
308
+ await new Promise((resolve) => setTimeout(resolve, 0));
309
+ await new Promise((resolve) => setTimeout(resolve, 0));
310
+ const moved = stripAnsi(rendered.lastFrame() ?? "");
311
+ expect(moved).not.toContain("[i implement]");
312
+ });
313
+ test("triggers implement helper for ready selection", async () => {
314
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-implement-action-"));
315
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
316
+ const doneRoot = join(baseDir, ".schub", "tasks", "done");
317
+ let didImplement = false;
318
+ let implemented = { id: "", repoRoot: "" };
319
+
320
+ const recordImplement = (id: string, repoRoot: string) => {
321
+ implemented = { id, repoRoot };
322
+ didImplement = true;
323
+ };
324
+
325
+ mkdirSync(readyRoot, { recursive: true });
326
+ mkdirSync(doneRoot, { recursive: true });
327
+
328
+ writeTask(doneRoot, "T0600", "done-task", "# Task: T0600 Done Task\n");
329
+ writeTask(readyRoot, "T0601", "ready-task", "---\ndepends_on:\n - T0600\n---\n# Task: T0601 Ready Task\n");
330
+
331
+ const rendered = render(<App startDir={baseDir} onImplement={recordImplement} />);
332
+ await new Promise((resolve) => setTimeout(resolve, 0));
333
+ await new Promise((resolve) => setTimeout(resolve, 0));
334
+
335
+ rendered.stdin.write("i");
336
+ await new Promise((resolve) => setTimeout(resolve, 0));
337
+ await new Promise((resolve) => setTimeout(resolve, 0));
338
+
339
+ expect(didImplement).toBe(true);
340
+ expect(implemented.id).toBe("T0601");
341
+ expect(realpathSync(implemented.repoRoot)).toBe(realpathSync(baseDir));
342
+ });
343
+ test("ignores implement helper when selection is not ready", async () => {
344
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-implement-noop-"));
345
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
346
+ const blockedRoot = join(baseDir, ".schub", "tasks", "blocked");
347
+ const doneRoot = join(baseDir, ".schub", "tasks", "done");
348
+ let didImplement = false;
349
+
350
+ const recordImplement = () => {
351
+ didImplement = true;
352
+ };
353
+
354
+ mkdirSync(readyRoot, { recursive: true });
355
+ mkdirSync(blockedRoot, { recursive: true });
356
+ mkdirSync(doneRoot, { recursive: true });
357
+
358
+ writeTask(doneRoot, "T0602", "done-task", "# Task: T0602 Done Task\n");
359
+ writeTask(readyRoot, "T0603", "ready-task", "---\ndepends_on:\n - T0602\n---\n# Task: T0603 Ready Task\n");
360
+ writeTask(blockedRoot, "T0604", "blocked-task", "# Task: T0604 Blocked Task\n");
361
+
362
+ const rendered = render(<App startDir={baseDir} onImplement={recordImplement} />);
363
+ await new Promise((resolve) => setTimeout(resolve, 0));
364
+ await new Promise((resolve) => setTimeout(resolve, 0));
365
+
366
+ rendered.stdin.write("\u001B[B");
367
+ await new Promise((resolve) => setTimeout(resolve, 0));
368
+ await new Promise((resolve) => setTimeout(resolve, 0));
369
+
370
+ rendered.stdin.write("i");
371
+ await new Promise((resolve) => setTimeout(resolve, 0));
372
+ await new Promise((resolve) => setTimeout(resolve, 0));
373
+
374
+ expect(didImplement).toBe(false);
375
+ });
376
+ test("selected tab shows a blue left indicator", () => {
377
+ const originalForceColor = process.env.FORCE_COLOR;
378
+ process.env.FORCE_COLOR = "1";
379
+
380
+ try {
381
+ const { lastFrame } = render(<App />);
382
+ const output = lastFrame() || "";
383
+ const statusLine = output.split("\n").find((line) => line.includes("Status")) ?? "";
384
+ expect(statusLine).toContain("Status");
385
+ expect(statusLine).not.toContain("│");
386
+ } finally {
387
+ if (originalForceColor === undefined) {
388
+ delete process.env.FORCE_COLOR;
389
+ } else {
390
+ process.env.FORCE_COLOR = originalForceColor;
391
+ }
392
+ }
393
+ });
394
+ test("shows a copy banner after copying an item", async () => {
395
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-copy-"));
396
+ const readyRoot = join(baseDir, ".schub", "tasks", "ready");
397
+ let copied = "";
398
+ const recordCopy = (value: string) => {
399
+ copied = value;
400
+ };
401
+
402
+ mkdirSync(readyRoot, { recursive: true });
403
+ writeFileSync(join(readyRoot, "T0900_copy-task.md"), "# Task: T0900 Copy Task\n", "utf8");
404
+
405
+ const rendered = render(<App startDir={baseDir} copyToClipboard={recordCopy} />);
406
+ rendered.stdin.write("c");
407
+ await new Promise((resolve) => setTimeout(resolve, 0));
408
+ await new Promise((resolve) => setTimeout(resolve, 0));
409
+ const output = rendered.lastFrame() ?? "";
410
+ expect(copied).toBe("T0900");
411
+ expect(output).toContain("Copied to clipboard !");
412
+ });
413
+ test("renders when no .schub directory is found", () => {
414
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-"));
415
+ const { lastFrame } = render(<App startDir={baseDir} />);
416
+ const output = lastFrame() ?? "";
417
+ expect(output).toContain("No .schub directory found.");
418
+ });