schub 0.1.3 → 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 (76) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +17231 -7669
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +4 -0
  5. package/skills/create-tasks/SKILL.md +2 -1
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +1 -0
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +119 -5
  10. package/src/changes.ts +136 -54
  11. package/src/commands/adr.test.ts +5 -4
  12. package/src/commands/changes.test.ts +6 -6
  13. package/src/commands/cookbook.test.ts +5 -4
  14. package/src/commands/init.ts +5 -0
  15. package/src/commands/review.test.ts +5 -4
  16. package/src/commands/review.ts +1 -1
  17. package/src/commands/roadmap.test.ts +84 -0
  18. package/src/commands/roadmap.ts +84 -0
  19. package/src/commands/tasks-create.test.ts +1 -1
  20. package/src/commands/tasks-implement.test.ts +253 -0
  21. package/src/commands/tasks-implement.ts +121 -0
  22. package/src/commands/tasks-update.test.ts +92 -0
  23. package/src/commands/tasks.ts +98 -1
  24. package/src/features/roadmap/index.ts +230 -0
  25. package/src/features/roadmap/roadmap.test.ts +77 -0
  26. package/src/features/tasks/constants.ts +1 -0
  27. package/src/features/tasks/create.ts +9 -7
  28. package/src/features/tasks/filesystem.test.ts +221 -4
  29. package/src/features/tasks/filesystem.ts +124 -40
  30. package/src/features/tasks/graph.ts +18 -3
  31. package/src/features/tasks/index.ts +10 -1
  32. package/src/features/tasks/worktree.ts +48 -0
  33. package/src/frontmatter.ts +115 -0
  34. package/src/index.test.ts +42 -6
  35. package/src/index.ts +225 -118
  36. package/src/opencode.test.ts +53 -0
  37. package/src/opencode.ts +71 -3
  38. package/src/tasks.ts +1 -0
  39. package/src/tui/App.test.tsx +418 -0
  40. package/src/tui/App.tsx +343 -0
  41. package/src/{components → tui/components}/PlanView.test.tsx +26 -38
  42. package/src/tui/components/PlanView.tsx +89 -0
  43. package/src/tui/components/PreviewPage.test.tsx +69 -0
  44. package/src/tui/components/PreviewPage.tsx +87 -0
  45. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  46. package/src/tui/components/ProposalDetailView.tsx +166 -0
  47. package/src/tui/components/RoadmapView.test.tsx +85 -0
  48. package/src/tui/components/RoadmapView.tsx +369 -0
  49. package/src/tui/components/StatusView.test.tsx +1351 -0
  50. package/src/tui/components/StatusView.tsx +519 -0
  51. package/src/tui/components/markdown-renderer.test.ts +46 -0
  52. package/src/tui/components/markdown-renderer.ts +89 -0
  53. package/src/tui/components/status-view-data.ts +322 -0
  54. package/src/tui/components/status-view-render.tsx +329 -0
  55. package/src/tui/index.ts +16 -0
  56. package/templates/create-proposal/adr-template.md +6 -4
  57. package/templates/create-proposal/cookbook-template.md +5 -3
  58. package/templates/create-proposal/proposal-template.md +8 -6
  59. package/templates/create-roadmap/roadmap.md +5 -0
  60. package/templates/create-tasks/task-template.md +9 -4
  61. package/templates/review-proposal/q&a-template.md +8 -3
  62. package/templates/review-proposal/review-me-template.md +6 -4
  63. package/templates/setup-project/project-overview-template.md +5 -0
  64. package/templates/setup-project/project-setup-template.md +5 -0
  65. package/templates/setup-project/project-wow-template.md +5 -0
  66. package/src/App.test.tsx +0 -145
  67. package/src/App.tsx +0 -172
  68. package/src/components/PlanView.tsx +0 -160
  69. package/src/components/StatusView.test.tsx +0 -432
  70. package/src/components/StatusView.tsx +0 -420
  71. package/src/ide.ts +0 -7
  72. package/templates/templates-parity.test.ts +0 -45
  73. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  74. /package/src/{components → tui/components}/statusColor.ts +0 -0
  75. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  76. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
@@ -0,0 +1,1351 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, unlinkSync, 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 StatusView from "./StatusView";
7
+ import { buildShowAllTaskGroups } from "./status-view-data";
8
+ import { StatusShowAllTasksView } from "./status-view-render";
9
+
10
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
11
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
12
+ const normalizeWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
13
+
14
+ const extractSection = (output: string, start: string, end?: string): string => {
15
+ const startIndex = output.indexOf(start);
16
+ if (startIndex === -1) {
17
+ return "";
18
+ }
19
+
20
+ const afterStart = output.slice(startIndex + start.length);
21
+ if (!end) {
22
+ return afterStart;
23
+ }
24
+ const endIndex = afterStart.indexOf(end);
25
+ if (endIndex === -1) {
26
+ return afterStart;
27
+ }
28
+
29
+ return afterStart.slice(0, endIndex);
30
+ };
31
+
32
+ const renders: Array<() => void> = [];
33
+ const render = (...args: Parameters<typeof inkRender>) => {
34
+ const rendered = inkRender(...args);
35
+ renders.push(rendered.unmount);
36
+ return rendered;
37
+ };
38
+
39
+ afterEach(() => {
40
+ for (const unmount of renders) {
41
+ unmount();
42
+ }
43
+ renders.length = 0;
44
+ });
45
+
46
+ const writeTask = (tasksRoot: string, id: string, slug: string, body: string) => {
47
+ const filePath = join(tasksRoot, `${id}_${slug}.md`);
48
+ writeFileSync(filePath, body, "utf8");
49
+ };
50
+
51
+ const writeProposal = (changesRoot: string, changeId: string, status: string, title = changeId) => {
52
+ const changeDir = join(changesRoot, changeId);
53
+ mkdirSync(changeDir, { recursive: true });
54
+ writeFileSync(join(changeDir, "proposal.md"), `---\nstatus: ${status}\n---\n# Proposal - ${title}\n`, "utf8");
55
+ };
56
+
57
+ const findLine = (output: string, id: string) => {
58
+ return output.split("\n").find((line) => line.includes(id)) ?? "";
59
+ };
60
+
61
+ const countOccurrences = (output: string, value: string) => output.split(value).length - 1;
62
+
63
+ const hasSelectionMarker = (output: string, id: string, marker: string) => {
64
+ return output.split("\n").some((line) => line.includes(id) && line.includes(marker));
65
+ };
66
+
67
+ const changeDisplayId = (value: string) => value.split("_")[0];
68
+
69
+ const noopCopy = () => {};
70
+
71
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
72
+
73
+ test("status view shows checklist indicator only for wip tasks", () => {
74
+ const originalCwd = process.cwd();
75
+ const base = mkdtempSync(join(tmpdir(), "schub-status-view-"));
76
+ const schubDir = join(base, ".schub");
77
+ const wipRoot = join(schubDir, "tasks", "wip");
78
+ const readyRoot = join(schubDir, "tasks", "ready");
79
+
80
+ mkdirSync(wipRoot, { recursive: true });
81
+ mkdirSync(readyRoot, { recursive: true });
82
+
83
+ writeTask(wipRoot, "T0100", "wip-checklist", "# Task: T0100 Wip Checklist\n\n## Steps\n- [ ] First\n- [x] Second\n");
84
+ writeTask(wipRoot, "T0101", "wip-complete", "# Task: T0101 Wip Complete\n\n## Steps\n- [x] First\n- [x] Second\n");
85
+ writeTask(wipRoot, "T0102", "wip-empty", "# Task: T0102 Wip Empty\n\n## Steps\n- Do the thing\n");
86
+ writeTask(readyRoot, "T0200", "ready-checklist", "# Task: T0200 Ready Checklist\n\n## Steps\n- [ ] First\n");
87
+
88
+ try {
89
+ process.chdir(base);
90
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
91
+ const output = stripAnsi(lastFrame() ?? "");
92
+
93
+ const wipChecklistLine = findLine(output, "T0100");
94
+ const wipCompleteLine = findLine(output, "T0101");
95
+ const wipEmptyLine = findLine(output, "T0102");
96
+ const readyLine = findLine(output, "T0200");
97
+
98
+ expect(wipChecklistLine).toContain("wip checklist");
99
+ expect(wipChecklistLine).toContain("(1/2)");
100
+ expect(wipCompleteLine).toContain("(0/2)");
101
+ expect(wipEmptyLine).toContain("wip empty");
102
+ expect(wipEmptyLine).not.toContain("wip empty (");
103
+ expect(readyLine).toContain("ready checklist");
104
+ expect(readyLine).not.toContain("ready checklist (");
105
+ } finally {
106
+ process.chdir(originalCwd);
107
+ }
108
+ });
109
+
110
+ test("status view shows blocked reasons for blocked tasks", () => {
111
+ const originalCwd = process.cwd();
112
+ const base = mkdtempSync(join(tmpdir(), "schub-status-blocked-reason-"));
113
+ const schubDir = join(base, ".schub");
114
+ const blockedRoot = join(schubDir, "tasks", "blocked");
115
+
116
+ mkdirSync(blockedRoot, { recursive: true });
117
+
118
+ writeTask(
119
+ blockedRoot,
120
+ "T0600",
121
+ "blocked-task",
122
+ "---\nblocked_reason: Waiting on API\n---\n# Task: T0600 Blocked Task\n",
123
+ );
124
+
125
+ try {
126
+ process.chdir(base);
127
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
128
+ const output = stripAnsi(lastFrame() ?? "");
129
+ const blockedLine = findLine(output, "T0600");
130
+
131
+ expect(blockedLine).toContain("Waiting on API");
132
+ expect(blockedLine).not.toContain("blocked task");
133
+ } finally {
134
+ process.chdir(originalCwd);
135
+ }
136
+ });
137
+
138
+ test("status view omits blocked titles when reasons are missing", () => {
139
+ const originalCwd = process.cwd();
140
+ const base = mkdtempSync(join(tmpdir(), "schub-status-blocked-empty-"));
141
+ const schubDir = join(base, ".schub");
142
+ const blockedRoot = join(schubDir, "tasks", "blocked");
143
+
144
+ mkdirSync(blockedRoot, { recursive: true });
145
+
146
+ writeTask(blockedRoot, "T0601", "missing-reason", "# Task: T0601 Missing Reason\n");
147
+
148
+ try {
149
+ process.chdir(base);
150
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
151
+ const output = stripAnsi(lastFrame() ?? "");
152
+ const blockedLine = findLine(output, "T0601");
153
+
154
+ expect(blockedLine).not.toContain("missing reason");
155
+ } finally {
156
+ process.chdir(originalCwd);
157
+ }
158
+ });
159
+
160
+ test("status view omits per-item shortcuts for tasks and proposals", () => {
161
+ const originalCwd = process.cwd();
162
+ const base = mkdtempSync(join(tmpdir(), "schub-status-actions-"));
163
+ const schubDir = join(base, ".schub");
164
+ const changesRoot = join(schubDir, "changes");
165
+ const readyRoot = join(schubDir, "tasks", "ready");
166
+
167
+ mkdirSync(changesRoot, { recursive: true });
168
+ mkdirSync(readyRoot, { recursive: true });
169
+
170
+ const changeId = "C0003_demo-change";
171
+ const displayId = changeDisplayId(changeId);
172
+
173
+ writeProposal(changesRoot, changeId, "Draft");
174
+ writeTask(readyRoot, "T0003", "ready-task", "# Task: T0003 Ready Task\n");
175
+
176
+ try {
177
+ process.chdir(base);
178
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
179
+ const output = stripAnsi(lastFrame() ?? "");
180
+
181
+ expect(findLine(output, displayId)).not.toContain("[o preview]");
182
+ expect(findLine(output, displayId)).not.toContain("[c copy id]");
183
+ expect(findLine(output, "T0003")).not.toContain("[o preview]");
184
+ expect(findLine(output, "T0003")).not.toContain("[c copy id]");
185
+ } finally {
186
+ process.chdir(originalCwd);
187
+ }
188
+ });
189
+
190
+ test("status view shows proposal titles", () => {
191
+ const originalCwd = process.cwd();
192
+ const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-title-"));
193
+ const schubDir = join(base, ".schub");
194
+ const changesRoot = join(schubDir, "changes");
195
+
196
+ mkdirSync(changesRoot, { recursive: true });
197
+
198
+ const changeId = "C0004_pretty-proposals";
199
+ const changeTitle = "Pretty Print Change Proposals";
200
+ const displayId = changeDisplayId(changeId);
201
+
202
+ writeProposal(changesRoot, changeId, "Draft", changeTitle);
203
+
204
+ try {
205
+ process.chdir(base);
206
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
207
+ const output = stripAnsi(lastFrame() ?? "");
208
+ const line = findLine(output, displayId);
209
+
210
+ expect(line).toContain(displayId);
211
+ expect(line).toContain(changeTitle);
212
+ expect(line).not.toContain(changeId);
213
+ } finally {
214
+ process.chdir(originalCwd);
215
+ }
216
+ });
217
+
218
+ test("status view formats default proposal titles from ids", () => {
219
+ const originalCwd = process.cwd();
220
+ const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-id-title-"));
221
+ const schubDir = join(base, ".schub");
222
+ const changesRoot = join(schubDir, "changes");
223
+
224
+ mkdirSync(changesRoot, { recursive: true });
225
+
226
+ const changeId = "C0001_pending-review-quick-action";
227
+ const displayId = changeDisplayId(changeId);
228
+
229
+ writeProposal(changesRoot, changeId, "Pending Review");
230
+
231
+ try {
232
+ process.chdir(base);
233
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
234
+ const output = stripAnsi(lastFrame() ?? "");
235
+ const line = findLine(output, displayId);
236
+
237
+ expect(line).toContain(displayId);
238
+ expect(line).toContain("Pending Review Quick Action");
239
+ expect(line).not.toContain("pending-review-quick-action");
240
+ } finally {
241
+ process.chdir(originalCwd);
242
+ }
243
+ });
244
+
245
+ test("status view copies the selected id on c", async () => {
246
+ const originalCwd = process.cwd();
247
+ const base = mkdtempSync(join(tmpdir(), "schub-status-copy-"));
248
+ const schubDir = join(base, ".schub");
249
+ const readyRoot = join(schubDir, "tasks", "ready");
250
+ let copied = "";
251
+ const recordCopy = (id: string) => {
252
+ copied = id;
253
+ };
254
+
255
+ mkdirSync(readyRoot, { recursive: true });
256
+ writeTask(readyRoot, "T0010", "copy-task", "# Task: T0010 Copy Task\n");
257
+
258
+ try {
259
+ process.chdir(base);
260
+ const rendered = render(<StatusView onCopyId={recordCopy} />);
261
+ rendered.stdin.write("c");
262
+ await new Promise((resolve) => setTimeout(resolve, 0));
263
+ expect(copied).toBe("T0010");
264
+ } finally {
265
+ process.chdir(originalCwd);
266
+ }
267
+ });
268
+
269
+ test("status view opens the selected item on o", async () => {
270
+ const originalCwd = process.cwd();
271
+ const base = mkdtempSync(join(tmpdir(), "schub-status-open-"));
272
+ const schubDir = join(base, ".schub");
273
+ const backlogRoot = join(schubDir, "tasks", "backlog");
274
+ let openedRoot = "";
275
+ let openedPath = "";
276
+ const recordOpen = (repoRoot: string, relativePath: string) => {
277
+ openedRoot = repoRoot;
278
+ openedPath = relativePath;
279
+ };
280
+
281
+ mkdirSync(backlogRoot, { recursive: true });
282
+ writeTask(backlogRoot, "T0800", "preview-task", "---\ndepends_on:\n - T9999\n---\n# Task: T0800 Preview Task\n");
283
+
284
+ try {
285
+ process.chdir(base);
286
+ const rendered = render(<StatusView onCopyId={noopCopy} onOpen={recordOpen} />);
287
+ await nextTick();
288
+ rendered.stdin.write("o");
289
+ await nextTick();
290
+ expect(realpathSync(openedRoot)).toBe(realpathSync(base));
291
+ expect(openedPath).toContain("tasks/backlog/T0800_preview-task.md");
292
+ } finally {
293
+ process.chdir(originalCwd);
294
+ }
295
+ });
296
+
297
+ test("status view reports implement shortcut only for ready selections", async () => {
298
+ const originalCwd = process.cwd();
299
+ const base = mkdtempSync(join(tmpdir(), "schub-status-implement-shortcut-"));
300
+ const schubDir = join(base, ".schub");
301
+ const readyRoot = join(schubDir, "tasks", "ready");
302
+ const doneRoot = join(schubDir, "tasks", "done");
303
+ const blockedRoot = join(schubDir, "tasks", "blocked");
304
+ let shortcuts: { keyLabel: string; label: string }[] = [];
305
+ const recordShortcuts = (value: { keyLabel: string; label: string }[]) => {
306
+ shortcuts = value;
307
+ };
308
+
309
+ mkdirSync(readyRoot, { recursive: true });
310
+ mkdirSync(doneRoot, { recursive: true });
311
+ mkdirSync(blockedRoot, { recursive: true });
312
+
313
+ writeTask(doneRoot, "T0508", "done-task", "# Task: T0508 Done Task\n");
314
+ writeTask(readyRoot, "T0509", "ready-task", "---\ndepends_on:\n - T0508\n---\n# Task: T0509 Ready Task\n");
315
+ writeTask(blockedRoot, "T0510", "blocked-task", "---\nblocked_reason: Waiting\n---\n# Task: T0510 Blocked Task\n");
316
+
317
+ try {
318
+ process.chdir(base);
319
+ const rendered = render(<StatusView onCopyId={noopCopy} onShortcutsChange={recordShortcuts} />);
320
+ await nextTick();
321
+ await nextTick();
322
+ expect(shortcuts).toContainEqual({ keyLabel: "i", label: "implement" });
323
+
324
+ rendered.stdin.write("\u001B[B");
325
+ await nextTick();
326
+ await nextTick();
327
+ expect(shortcuts).not.toContainEqual({ keyLabel: "i", label: "implement" });
328
+ } finally {
329
+ process.chdir(originalCwd);
330
+ }
331
+ });
332
+
333
+ test("status view reports status shortcut for ready tasks", async () => {
334
+ const originalCwd = process.cwd();
335
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-shortcut-"));
336
+ const schubDir = join(base, ".schub");
337
+ const readyRoot = join(schubDir, "tasks", "ready");
338
+ let shortcuts: { keyLabel: string; label: string }[] = [];
339
+ const recordShortcuts = (value: { keyLabel: string; label: string }[]) => {
340
+ shortcuts = value;
341
+ };
342
+
343
+ mkdirSync(readyRoot, { recursive: true });
344
+ writeTask(readyRoot, "T0514", "ready-task", "# Task: T0514 Ready Task\n");
345
+
346
+ try {
347
+ process.chdir(base);
348
+ render(<StatusView onCopyId={noopCopy} onShortcutsChange={recordShortcuts} />);
349
+ await nextTick();
350
+ await nextTick();
351
+ expect(shortcuts).toContainEqual({ keyLabel: "s", label: "status" });
352
+ } finally {
353
+ process.chdir(originalCwd);
354
+ }
355
+ });
356
+
357
+ test("status view reports status shortcut in tasks page", async () => {
358
+ const originalCwd = process.cwd();
359
+ const base = mkdtempSync(join(tmpdir(), "schub-status-tasks-shortcut-"));
360
+ const schubDir = join(base, ".schub");
361
+ const backlogRoot = join(schubDir, "tasks", "backlog");
362
+ let shortcuts: { keyLabel: string; label: string }[] = [];
363
+ const recordShortcuts = (value: { keyLabel: string; label: string }[]) => {
364
+ shortcuts = value;
365
+ };
366
+
367
+ mkdirSync(backlogRoot, { recursive: true });
368
+ writeTask(backlogRoot, "T0515", "backlog-task", "# Task: T0515 Backlog Task\n");
369
+
370
+ try {
371
+ process.chdir(base);
372
+ const rendered = render(<StatusView onCopyId={noopCopy} onShortcutsChange={recordShortcuts} />);
373
+ await nextTick();
374
+ rendered.stdin.write("t");
375
+ await new Promise((resolve) => setTimeout(resolve, 20));
376
+ expect(shortcuts).toContainEqual({ keyLabel: "s", label: "status" });
377
+ } finally {
378
+ process.chdir(originalCwd);
379
+ }
380
+ });
381
+
382
+ test("status view ignores implement shortcut when selection is not ready", async () => {
383
+ const originalCwd = process.cwd();
384
+ const base = mkdtempSync(join(tmpdir(), "schub-status-implement-ignore-"));
385
+ const schubDir = join(base, ".schub");
386
+ const readyRoot = join(schubDir, "tasks", "ready");
387
+ const doneRoot = join(schubDir, "tasks", "done");
388
+ const blockedRoot = join(schubDir, "tasks", "blocked");
389
+ let didImplement = false;
390
+ const recordImplement = () => {
391
+ didImplement = true;
392
+ };
393
+
394
+ mkdirSync(readyRoot, { recursive: true });
395
+ mkdirSync(doneRoot, { recursive: true });
396
+ mkdirSync(blockedRoot, { recursive: true });
397
+
398
+ writeTask(doneRoot, "T0511", "done-task", "# Task: T0511 Done Task\n");
399
+ writeTask(readyRoot, "T0512", "ready-task", "---\ndepends_on:\n - T0511\n---\n# Task: T0512 Ready Task\n");
400
+ writeTask(blockedRoot, "T0513", "blocked-task", "---\nblocked_reason: Waiting\n---\n# Task: T0513 Blocked Task\n");
401
+
402
+ try {
403
+ process.chdir(base);
404
+ const rendered = render(<StatusView onCopyId={noopCopy} onImplement={recordImplement} />);
405
+ await nextTick();
406
+ rendered.stdin.write("\u001B[B");
407
+ await nextTick();
408
+ rendered.stdin.write("i");
409
+ await nextTick();
410
+ expect(didImplement).toBe(false);
411
+ } finally {
412
+ process.chdir(originalCwd);
413
+ }
414
+ });
415
+
416
+ test("status view triggers implement on i for ready to implement tasks", async () => {
417
+ const originalCwd = process.cwd();
418
+ const base = mkdtempSync(join(tmpdir(), "schub-status-implement-"));
419
+ const schubDir = join(base, ".schub");
420
+ const readyRoot = join(schubDir, "tasks", "ready");
421
+ const doneRoot = join(schubDir, "tasks", "done");
422
+ let didImplement = false;
423
+ let implemented = { id: "", repoRoot: "" };
424
+ const recordImplement = (id: string, repoRoot: string) => {
425
+ implemented = { id, repoRoot };
426
+ didImplement = true;
427
+ };
428
+
429
+ mkdirSync(readyRoot, { recursive: true });
430
+ mkdirSync(doneRoot, { recursive: true });
431
+
432
+ writeTask(doneRoot, "T0500", "done-task", "# Task: T0500 Done Task\n");
433
+ writeTask(readyRoot, "T0501", "ready-task", "---\ndepends_on:\n - T0500\n---\n# Task: T0501 Ready Task\n");
434
+
435
+ try {
436
+ process.chdir(base);
437
+ const rendered = render(<StatusView onCopyId={noopCopy} onImplement={recordImplement} />);
438
+ rendered.stdin.write("i");
439
+ await new Promise((resolve) => setTimeout(resolve, 0));
440
+
441
+ expect(didImplement).toBe(true);
442
+ expect(implemented.id).toBe("T0501");
443
+ expect(realpathSync(implemented.repoRoot)).toBe(realpathSync(base));
444
+ } finally {
445
+ process.chdir(originalCwd);
446
+ }
447
+ });
448
+
449
+ test("status view opens backlog status modal on s", async () => {
450
+ const originalCwd = process.cwd();
451
+ const base = mkdtempSync(join(tmpdir(), "schub-status-backlog-modal-"));
452
+ const schubDir = join(base, ".schub");
453
+ const backlogRoot = join(schubDir, "tasks", "backlog");
454
+
455
+ mkdirSync(backlogRoot, { recursive: true });
456
+ writeTask(backlogRoot, "T0600", "backlog-task", "# Task: T0600 Backlog Task\n");
457
+
458
+ try {
459
+ process.chdir(base);
460
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
461
+ await nextTick();
462
+ rendered.stdin.write("s");
463
+ await nextTick();
464
+
465
+ const output = normalizeWhitespace(stripAnsi(rendered.lastFrame() ?? ""));
466
+
467
+ expect(output).toContain("› Ready");
468
+ expect(output).toContain("Archive");
469
+ expect(output).toContain("enter confirm");
470
+ } finally {
471
+ process.chdir(originalCwd);
472
+ }
473
+ });
474
+
475
+ test("status view moves backlog task to ready from modal", async () => {
476
+ const originalCwd = process.cwd();
477
+ const base = mkdtempSync(join(tmpdir(), "schub-status-backlog-ready-"));
478
+ const schubDir = join(base, ".schub");
479
+ const backlogRoot = join(schubDir, "tasks", "backlog");
480
+ const readyRoot = join(schubDir, "tasks", "ready");
481
+ const backlogFile = join(backlogRoot, "T0601_backlog-task.md");
482
+ const readyFile = join(readyRoot, "T0601_backlog-task.md");
483
+
484
+ mkdirSync(backlogRoot, { recursive: true });
485
+ writeTask(backlogRoot, "T0601", "backlog-task", "# Task: T0601 Backlog Task\n");
486
+
487
+ try {
488
+ process.chdir(base);
489
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
490
+ rendered.stdin.write("s");
491
+ await nextTick();
492
+ rendered.stdin.write("\n");
493
+ await new Promise((resolve) => setTimeout(resolve, 20));
494
+
495
+ expect(existsSync(readyFile)).toBe(true);
496
+ expect(existsSync(backlogFile)).toBe(false);
497
+
498
+ const output = stripAnsi(rendered.lastFrame() ?? "");
499
+ expect(output).not.toContain("Move backlog task");
500
+ } finally {
501
+ process.chdir(originalCwd);
502
+ }
503
+ });
504
+
505
+ test("status view moves backlog task to archived from modal", async () => {
506
+ const originalCwd = process.cwd();
507
+ const base = mkdtempSync(join(tmpdir(), "schub-status-backlog-archive-"));
508
+ const schubDir = join(base, ".schub");
509
+ const backlogRoot = join(schubDir, "tasks", "backlog");
510
+ const archiveRoot = join(schubDir, "tasks", "archived");
511
+ const backlogFile = join(backlogRoot, "T0602_backlog-task.md");
512
+ const archiveFile = join(archiveRoot, "T0602_backlog-task.md");
513
+
514
+ mkdirSync(backlogRoot, { recursive: true });
515
+ writeTask(backlogRoot, "T0602", "backlog-task", "# Task: T0602 Backlog Task\n");
516
+
517
+ try {
518
+ process.chdir(base);
519
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
520
+ await nextTick();
521
+ rendered.stdin.write("s");
522
+ await new Promise((resolve) => setTimeout(resolve, 20));
523
+ rendered.stdin.write("\u001B[B");
524
+ await new Promise((resolve) => setTimeout(resolve, 20));
525
+ rendered.stdin.write("\n");
526
+ await new Promise((resolve) => setTimeout(resolve, 20));
527
+
528
+ expect(existsSync(archiveFile)).toBe(true);
529
+ expect(existsSync(backlogFile)).toBe(false);
530
+ } finally {
531
+ process.chdir(originalCwd);
532
+ }
533
+ });
534
+
535
+ test("status view opens ready status modal on s", async () => {
536
+ const originalCwd = process.cwd();
537
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-modal-"));
538
+ const schubDir = join(base, ".schub");
539
+ const readyRoot = join(schubDir, "tasks", "ready");
540
+
541
+ mkdirSync(readyRoot, { recursive: true });
542
+ writeTask(readyRoot, "T0603", "ready-task", "# Task: T0603 Ready Task\n");
543
+
544
+ try {
545
+ process.chdir(base);
546
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
547
+ await nextTick();
548
+ rendered.stdin.write("s");
549
+ await nextTick();
550
+
551
+ const output = normalizeWhitespace(stripAnsi(rendered.lastFrame() ?? ""));
552
+
553
+ expect(output).toContain("› Backlog");
554
+ expect(output).toContain("Archive");
555
+ expect(output).toContain("enter confirm");
556
+ } finally {
557
+ process.chdir(originalCwd);
558
+ }
559
+ });
560
+
561
+ test("status view moves ready task to backlog from modal", async () => {
562
+ const originalCwd = process.cwd();
563
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-backlog-"));
564
+ const schubDir = join(base, ".schub");
565
+ const readyRoot = join(schubDir, "tasks", "ready");
566
+ const backlogRoot = join(schubDir, "tasks", "backlog");
567
+ const readyFile = join(readyRoot, "T0604_ready-task.md");
568
+ const backlogFile = join(backlogRoot, "T0604_ready-task.md");
569
+
570
+ mkdirSync(readyRoot, { recursive: true });
571
+ writeTask(readyRoot, "T0604", "ready-task", "# Task: T0604 Ready Task\n");
572
+
573
+ try {
574
+ process.chdir(base);
575
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
576
+ rendered.stdin.write("s");
577
+ await nextTick();
578
+ rendered.stdin.write("\n");
579
+ await new Promise((resolve) => setTimeout(resolve, 20));
580
+
581
+ expect(existsSync(backlogFile)).toBe(true);
582
+ expect(existsSync(readyFile)).toBe(false);
583
+ } finally {
584
+ process.chdir(originalCwd);
585
+ }
586
+ });
587
+
588
+ test("status view moves ready task to archived from modal", async () => {
589
+ const originalCwd = process.cwd();
590
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-archive-"));
591
+ const schubDir = join(base, ".schub");
592
+ const readyRoot = join(schubDir, "tasks", "ready");
593
+ const archiveRoot = join(schubDir, "tasks", "archived");
594
+ const readyFile = join(readyRoot, "T0605_ready-task.md");
595
+ const archiveFile = join(archiveRoot, "T0605_ready-task.md");
596
+
597
+ mkdirSync(readyRoot, { recursive: true });
598
+ writeTask(readyRoot, "T0605", "ready-task", "# Task: T0605 Ready Task\n");
599
+
600
+ try {
601
+ process.chdir(base);
602
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
603
+ await nextTick();
604
+ rendered.stdin.write("s");
605
+ await new Promise((resolve) => setTimeout(resolve, 20));
606
+ rendered.stdin.write("\u001B[B");
607
+ await new Promise((resolve) => setTimeout(resolve, 20));
608
+ rendered.stdin.write("\n");
609
+ await new Promise((resolve) => setTimeout(resolve, 20));
610
+
611
+ expect(existsSync(archiveFile)).toBe(true);
612
+ expect(existsSync(readyFile)).toBe(false);
613
+ } finally {
614
+ process.chdir(originalCwd);
615
+ }
616
+ });
617
+
618
+ test("status view ignores status shortcut for non-ready tasks", async () => {
619
+ const originalCwd = process.cwd();
620
+ const base = mkdtempSync(join(tmpdir(), "schub-status-non-ready-"));
621
+ const schubDir = join(base, ".schub");
622
+ const wipRoot = join(schubDir, "tasks", "wip");
623
+ const wipFile = join(wipRoot, "T0606_wip-task.md");
624
+
625
+ mkdirSync(wipRoot, { recursive: true });
626
+ writeTask(wipRoot, "T0606", "wip-task", "# Task: T0606 WIP Task\n");
627
+
628
+ try {
629
+ process.chdir(base);
630
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
631
+ rendered.stdin.write("s");
632
+ await nextTick();
633
+
634
+ const output = normalizeWhitespace(stripAnsi(rendered.lastFrame() ?? ""));
635
+ expect(output).not.toContain("enter confirm");
636
+ expect(existsSync(wipFile)).toBe(true);
637
+ } finally {
638
+ process.chdir(originalCwd);
639
+ }
640
+ });
641
+
642
+ test("status view shows pending implementation counts and no tasks defined section", () => {
643
+ const originalCwd = process.cwd();
644
+ const base = mkdtempSync(join(tmpdir(), "schub-status-proposals-"));
645
+ const schubDir = join(base, ".schub");
646
+ const changesRoot = join(schubDir, "changes");
647
+ const backlogRoot = join(schubDir, "tasks", "backlog");
648
+ const doneRoot = join(schubDir, "tasks", "done");
649
+
650
+ mkdirSync(backlogRoot, { recursive: true });
651
+ mkdirSync(doneRoot, { recursive: true });
652
+ mkdirSync(changesRoot, { recursive: true });
653
+
654
+ const changeWithTasks = "C0001_with-tasks";
655
+ const changeNoTasks = "C0002_no-tasks";
656
+ const displayWithTasks = changeDisplayId(changeWithTasks);
657
+ const displayNoTasks = changeDisplayId(changeNoTasks);
658
+
659
+ writeProposal(changesRoot, changeWithTasks, "Accepted");
660
+ writeProposal(changesRoot, changeNoTasks, "Accepted");
661
+
662
+ writeTask(backlogRoot, "T0010", "first-task", `---\nchange_id: ${changeWithTasks}\n---\n# Task: T0010 First Task\n`);
663
+ writeTask(
664
+ backlogRoot,
665
+ "T0011",
666
+ "second-task",
667
+ `---\nchange_id: ${changeWithTasks}\n---\n# Task: T0011 Second Task\n`,
668
+ );
669
+ writeTask(doneRoot, "T0012", "done-task", `---\nchange_id: ${changeWithTasks}\n---\n# Task: T0012 Done Task\n`);
670
+
671
+ try {
672
+ process.chdir(base);
673
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
674
+ const output = stripAnsi(lastFrame() ?? "");
675
+
676
+ expect(output).toContain("No Tasks Defined");
677
+
678
+ const withTasksLine = findLine(output, displayWithTasks);
679
+ expect(withTasksLine).toContain("1/3");
680
+
681
+ const noTasksLine = findLine(output, displayNoTasks);
682
+ expect(noTasksLine).not.toContain("/");
683
+ } finally {
684
+ process.chdir(originalCwd);
685
+ }
686
+ });
687
+
688
+ test("status view sorts ready to implement tasks alphabetically", () => {
689
+ const originalCwd = process.cwd();
690
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-sorted-"));
691
+ const schubDir = join(base, ".schub");
692
+ const backlogRoot = join(schubDir, "tasks", "backlog");
693
+ const readyRoot = join(schubDir, "tasks", "ready");
694
+ const doneRoot = join(schubDir, "tasks", "done");
695
+ const blockedRoot = join(schubDir, "tasks", "blocked");
696
+
697
+ mkdirSync(backlogRoot, { recursive: true });
698
+ mkdirSync(readyRoot, { recursive: true });
699
+ mkdirSync(doneRoot, { recursive: true });
700
+ mkdirSync(blockedRoot, { recursive: true });
701
+
702
+ writeTask(doneRoot, "T0400", "done-task", "# Task: T0400 Done Task\n");
703
+ writeTask(doneRoot, "T0401", "done-task-two", "# Task: T0401 Done Task Two\n");
704
+ writeTask(backlogRoot, "T0402", "zulu-task", "---\ndepends_on:\n - T0400\n---\n# Task: T0402 Zulu Task\n");
705
+ writeTask(readyRoot, "T0403", "alpha-task", "---\ndepends_on:\n - T0401\n---\n# Task: T0403 Alpha Task\n");
706
+ writeTask(blockedRoot, "T0404", "blocked-task", "---\nblocked_reason: Waiting\n---\n# Task: T0404 Blocked Task\n");
707
+
708
+ try {
709
+ process.chdir(base);
710
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
711
+ const output = stripAnsi(lastFrame() ?? "");
712
+ const readySection = extractSection(output, "Ready to Implement", "Blocked");
713
+ const alphaIndex = readySection.indexOf("alpha task");
714
+ const zuluIndex = readySection.indexOf("zulu task");
715
+
716
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
717
+ expect(zuluIndex).toBeGreaterThanOrEqual(0);
718
+ expect(alphaIndex).toBeLessThan(zuluIndex);
719
+ } finally {
720
+ process.chdir(originalCwd);
721
+ }
722
+ });
723
+
724
+ test("status view shows ready to implement tasks above task sections", () => {
725
+ const originalCwd = process.cwd();
726
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-section-"));
727
+ const schubDir = join(base, ".schub");
728
+ const backlogRoot = join(schubDir, "tasks", "backlog");
729
+ const readyRoot = join(schubDir, "tasks", "ready");
730
+ const doneRoot = join(schubDir, "tasks", "done");
731
+
732
+ mkdirSync(backlogRoot, { recursive: true });
733
+ mkdirSync(readyRoot, { recursive: true });
734
+ mkdirSync(doneRoot, { recursive: true });
735
+
736
+ writeTask(doneRoot, "T0300", "done-task", "# Task: T0300 Done Task\n");
737
+ writeTask(backlogRoot, "T0301", "backlog-task", "---\ndepends_on:\n - T0300\n---\n# Task: T0301 Backlog Task\n");
738
+ writeTask(readyRoot, "T0302", "ready-task", "# Task: T0302 Ready Task\n");
739
+
740
+ try {
741
+ process.chdir(base);
742
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
743
+ const output = stripAnsi(lastFrame() ?? "");
744
+
745
+ expect(output).toContain("Ready to Implement");
746
+ expect(output.indexOf("Ready to Implement")).toBeLessThan(output.indexOf("Backlog"));
747
+ expect(countOccurrences(output, "T0301")).toBe(2);
748
+ expect(countOccurrences(output, "T0302")).toBe(2);
749
+ expect(output).not.toContain("No tasks ready for implementation.");
750
+ } finally {
751
+ process.chdir(originalCwd);
752
+ }
753
+ });
754
+
755
+ test("status view shows ready to implement empty state when none qualify", () => {
756
+ const originalCwd = process.cwd();
757
+ const base = mkdtempSync(join(tmpdir(), "schub-status-ready-empty-"));
758
+ const schubDir = join(base, ".schub");
759
+ const backlogRoot = join(schubDir, "tasks", "backlog");
760
+ const readyRoot = join(schubDir, "tasks", "ready");
761
+
762
+ mkdirSync(backlogRoot, { recursive: true });
763
+ mkdirSync(readyRoot, { recursive: true });
764
+
765
+ writeTask(readyRoot, "T0401", "ready-task", "---\ndepends_on:\n - T0402\n---\n# Task: T0401 Ready Task\n");
766
+ writeTask(backlogRoot, "T0402", "backlog-task", "---\ndepends_on:\n - T0403\n---\n# Task: T0402 Backlog Task\n");
767
+ writeTask(backlogRoot, "T0403", "waiting-task", "---\ndepends_on:\n - T0404\n---\n# Task: T0403 Waiting Task\n");
768
+
769
+ try {
770
+ process.chdir(base);
771
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
772
+ const output = stripAnsi(lastFrame() ?? "");
773
+
774
+ expect(output).toContain("Ready to Implement");
775
+ expect(output).toContain("No tasks ready for implementation.");
776
+ } finally {
777
+ process.chdir(originalCwd);
778
+ }
779
+ });
780
+
781
+ test("status view hides pending implementation when tasks complete", () => {
782
+ const originalCwd = process.cwd();
783
+ const base = mkdtempSync(join(tmpdir(), "schub-status-complete-"));
784
+ const schubDir = join(base, ".schub");
785
+ const changesRoot = join(schubDir, "changes");
786
+ const doneRoot = join(schubDir, "tasks", "done");
787
+
788
+ mkdirSync(changesRoot, { recursive: true });
789
+ mkdirSync(doneRoot, { recursive: true });
790
+
791
+ const completedChange = "C0003_all-done";
792
+
793
+ writeProposal(changesRoot, completedChange, "Accepted");
794
+ writeTask(doneRoot, "T0020", "done-task", `---\nchange_id: ${completedChange}\n---\n# Task: T0020 Done Task\n`);
795
+ writeTask(doneRoot, "T0021", "done-task-two", `---\nchange_id: ${completedChange}\n---\n# Task: T0021 Done Task\n`);
796
+
797
+ try {
798
+ process.chdir(base);
799
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
800
+ const output = stripAnsi(lastFrame() ?? "");
801
+
802
+ expect(output).not.toContain(completedChange);
803
+ } finally {
804
+ process.chdir(originalCwd);
805
+ }
806
+ });
807
+
808
+ test("status view auto-marks completed accepted changes as done", async () => {
809
+ const originalCwd = process.cwd();
810
+ const base = mkdtempSync(join(tmpdir(), "schub-status-auto-done-"));
811
+ const schubDir = join(base, ".schub");
812
+ const changesRoot = join(schubDir, "changes");
813
+ const doneRoot = join(schubDir, "tasks", "done");
814
+ const refreshIntervalMs = 50;
815
+ let unmount: (() => void) | undefined;
816
+
817
+ mkdirSync(changesRoot, { recursive: true });
818
+ mkdirSync(doneRoot, { recursive: true });
819
+
820
+ const changeId = "C0011_auto-done";
821
+ const proposalPath = join(changesRoot, changeId, "proposal.md");
822
+
823
+ writeProposal(changesRoot, changeId, "Accepted");
824
+ writeTask(doneRoot, "T0100", "done-task", `---\nchange_id: ${changeId}\n---\n# Task: T0100 Done Task\n`);
825
+ writeTask(doneRoot, "T0101", "done-task-two", `---\nchange_id: ${changeId}\n---\n# Task: T0101 Done Task\n`);
826
+
827
+ try {
828
+ process.chdir(base);
829
+ const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
830
+ unmount = rendered.unmount;
831
+
832
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
833
+
834
+ const updated = readFileSync(proposalPath, "utf8");
835
+ expect(updated).toContain("status: Done");
836
+ } finally {
837
+ unmount?.();
838
+ process.chdir(originalCwd);
839
+ }
840
+ });
841
+
842
+ test("status view does not auto-mark proposals without tasks", async () => {
843
+ const originalCwd = process.cwd();
844
+ const base = mkdtempSync(join(tmpdir(), "schub-status-no-tasks-"));
845
+ const schubDir = join(base, ".schub");
846
+ const changesRoot = join(schubDir, "changes");
847
+ const refreshIntervalMs = 50;
848
+ let unmount: (() => void) | undefined;
849
+
850
+ mkdirSync(changesRoot, { recursive: true });
851
+
852
+ const changeId = "C0012_no-tasks";
853
+ const proposalPath = join(changesRoot, changeId, "proposal.md");
854
+
855
+ writeProposal(changesRoot, changeId, "Accepted");
856
+
857
+ try {
858
+ process.chdir(base);
859
+ const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
860
+ unmount = rendered.unmount;
861
+
862
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
863
+
864
+ const updated = readFileSync(proposalPath, "utf8");
865
+ expect(updated).toContain("status: Accepted");
866
+ } finally {
867
+ unmount?.();
868
+ process.chdir(originalCwd);
869
+ }
870
+ });
871
+
872
+ test("status view refreshes when tasks change", async () => {
873
+ const originalCwd = process.cwd();
874
+ const base = mkdtempSync(join(tmpdir(), "schub-status-refresh-"));
875
+ const schubDir = join(base, ".schub");
876
+ const readyRoot = join(schubDir, "tasks", "ready");
877
+ const refreshIntervalMs = 50;
878
+ let unmount: (() => void) | undefined;
879
+
880
+ mkdirSync(readyRoot, { recursive: true });
881
+
882
+ try {
883
+ process.chdir(base);
884
+ const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
885
+ unmount = rendered.unmount;
886
+ const output = stripAnsi(rendered.lastFrame() ?? "");
887
+ expect(output).toContain("No active changes or tasks found.");
888
+
889
+ writeTask(readyRoot, "T0001", "new-task", "# Task: T0001 New Task\n");
890
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
891
+
892
+ const refreshed = stripAnsi(rendered.lastFrame() ?? "");
893
+ expect(refreshed).toContain("T0001");
894
+ } finally {
895
+ unmount?.();
896
+ process.chdir(originalCwd);
897
+ }
898
+ });
899
+
900
+ test("status view shows show-all tasks row in the tasks section", () => {
901
+ const originalCwd = process.cwd();
902
+ const base = mkdtempSync(join(tmpdir(), "schub-status-show-all-tasks-row-"));
903
+ const schubDir = join(base, ".schub");
904
+ const backlogRoot = join(schubDir, "tasks", "backlog");
905
+
906
+ mkdirSync(backlogRoot, { recursive: true });
907
+ writeTask(backlogRoot, "T0700", "backlog-task", "# Task: T0700 Backlog Task\n");
908
+
909
+ try {
910
+ process.chdir(base);
911
+ const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
912
+ const output = stripAnsi(lastFrame() ?? "");
913
+ const rowLine = findLine(output, "Show all tasks");
914
+
915
+ expect(rowLine).toContain("Show all tasks");
916
+ expect(output.indexOf("Tasks")).toBeLessThan(output.indexOf("Show all tasks"));
917
+ } finally {
918
+ process.chdir(originalCwd);
919
+ }
920
+ });
921
+
922
+ test("status view opens tasks page with enter and returns on escape", async () => {
923
+ const originalCwd = process.cwd();
924
+ const base = mkdtempSync(join(tmpdir(), "schub-status-show-all-tasks-enter-"));
925
+ const schubDir = join(base, ".schub");
926
+ const backlogRoot = join(schubDir, "tasks", "backlog");
927
+
928
+ mkdirSync(backlogRoot, { recursive: true });
929
+ writeTask(backlogRoot, "T0710", "backlog-task", "# Task: T0710 Backlog Task\n");
930
+
931
+ try {
932
+ process.chdir(base);
933
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
934
+ const { stdin } = rendered;
935
+
936
+ await nextTick();
937
+ stdin.write("\u001B[B");
938
+ await nextTick();
939
+ stdin.write("\u001B[B");
940
+ await nextTick();
941
+ stdin.write("\r");
942
+ await new Promise((resolve) => setTimeout(resolve, 20));
943
+
944
+ const restored = stripAnsi(rendered.lastFrame() ?? "");
945
+ expect(restored).toContain("Change Proposals");
946
+ } finally {
947
+ process.chdir(originalCwd);
948
+ }
949
+ });
950
+
951
+ test("status view handles tasks page actions", async () => {
952
+ const originalCwd = process.cwd();
953
+ const base = mkdtempSync(join(tmpdir(), "schub-status-tasks-actions-"));
954
+ const schubDir = join(base, ".schub");
955
+ const backlogRoot = join(schubDir, "tasks", "backlog");
956
+ const readyRoot = join(schubDir, "tasks", "ready");
957
+ const doneRoot = join(schubDir, "tasks", "done");
958
+ let copied = "";
959
+ let didImplement = false;
960
+ let implemented = { id: "", repoRoot: "" };
961
+ const opened: string[] = [];
962
+
963
+ mkdirSync(backlogRoot, { recursive: true });
964
+ mkdirSync(readyRoot, { recursive: true });
965
+ mkdirSync(doneRoot, { recursive: true });
966
+
967
+ writeTask(backlogRoot, "T0720", "backlog-task", "---\ndepends_on:\n - T0999\n---\n# Task: T0720 Backlog Task\n");
968
+ writeTask(doneRoot, "T0722", "done-task", "# Task: T0722 Done Task\n");
969
+ writeTask(readyRoot, "T0721", "ready-task", "---\ndepends_on:\n - T0722\n---\n# Task: T0721 Ready Task\n");
970
+
971
+ try {
972
+ process.chdir(base);
973
+ const rendered = render(
974
+ <StatusView
975
+ onCopyId={(id) => {
976
+ copied = id;
977
+ }}
978
+ onOpen={(_, path) => {
979
+ opened.push(path);
980
+ }}
981
+ onImplement={(id: string, repoRoot: string) => {
982
+ implemented = { id, repoRoot };
983
+ didImplement = true;
984
+ }}
985
+ />,
986
+ );
987
+ const { stdin } = rendered;
988
+
989
+ await nextTick();
990
+ stdin.write("t");
991
+ await new Promise((resolve) => setTimeout(resolve, 20));
992
+
993
+ const subpage = stripAnsi(rendered.lastFrame() ?? "");
994
+ expect(subpage).toContain("All Tasks");
995
+
996
+ stdin.write("c");
997
+ await nextTick();
998
+ expect(copied).toBe("T0720");
999
+
1000
+ stdin.write("o");
1001
+ await nextTick();
1002
+ expect(opened[0]).toContain("tasks/backlog/T0720_backlog-task.md");
1003
+
1004
+ stdin.write("s");
1005
+ await nextTick();
1006
+ const modal = normalizeWhitespace(stripAnsi(rendered.lastFrame() ?? ""));
1007
+ expect(modal).toContain("enter confirm");
1008
+
1009
+ stdin.write("\u001B");
1010
+ await new Promise((resolve) => setTimeout(resolve, 20));
1011
+
1012
+ stdin.write("\u001B[B");
1013
+ await new Promise((resolve) => setTimeout(resolve, 20));
1014
+ stdin.write("i");
1015
+ await nextTick();
1016
+
1017
+ expect(didImplement).toBe(true);
1018
+ expect(implemented.id).toBe("T0721");
1019
+ expect(realpathSync(implemented.repoRoot)).toBe(realpathSync(base));
1020
+
1021
+ stdin.write("q");
1022
+ await new Promise((resolve) => setTimeout(resolve, 20));
1023
+
1024
+ const restored = stripAnsi(rendered.lastFrame() ?? "");
1025
+ expect(restored).toContain("Change Proposals");
1026
+ } finally {
1027
+ process.chdir(originalCwd);
1028
+ }
1029
+ });
1030
+
1031
+ test("status view opens proposal detail on enter from main list", async () => {
1032
+ const originalCwd = process.cwd();
1033
+ const base = mkdtempSync(join(tmpdir(), "schub-status-detail-enter-"));
1034
+ const schubDir = join(base, ".schub");
1035
+ const changesRoot = join(schubDir, "changes");
1036
+ const changeId = "C1300_detail-view";
1037
+ let opened = "";
1038
+
1039
+ mkdirSync(changesRoot, { recursive: true });
1040
+ writeProposal(changesRoot, changeId, "Draft", "Detail View");
1041
+
1042
+ try {
1043
+ process.chdir(base);
1044
+ const rendered = render(
1045
+ <StatusView
1046
+ onCopyId={noopCopy}
1047
+ onOpenDetail={(value: string) => {
1048
+ opened = value;
1049
+ }}
1050
+ />,
1051
+ );
1052
+
1053
+ await nextTick();
1054
+ await nextTick();
1055
+
1056
+ rendered.stdin.write("\n");
1057
+ await nextTick();
1058
+ await nextTick();
1059
+
1060
+ expect(opened).toBe(changeId);
1061
+ } finally {
1062
+ process.chdir(originalCwd);
1063
+ }
1064
+ });
1065
+
1066
+ test("status view opens proposal detail on enter from show-all list", async () => {
1067
+ const originalCwd = process.cwd();
1068
+ const base = mkdtempSync(join(tmpdir(), "schub-status-detail-show-all-"));
1069
+ const schubDir = join(base, ".schub");
1070
+ const changesRoot = join(schubDir, "changes");
1071
+ const changeId = "C1301_detail-show-all";
1072
+ let opened = "";
1073
+
1074
+ mkdirSync(changesRoot, { recursive: true });
1075
+ writeProposal(changesRoot, changeId, "Draft", "Detail Show All");
1076
+
1077
+ try {
1078
+ process.chdir(base);
1079
+ const rendered = render(
1080
+ <StatusView
1081
+ onCopyId={noopCopy}
1082
+ onOpenDetail={(value: string) => {
1083
+ opened = value;
1084
+ }}
1085
+ />,
1086
+ );
1087
+
1088
+ await nextTick();
1089
+ await nextTick();
1090
+
1091
+ rendered.stdin.write("p");
1092
+ await nextTick();
1093
+ await nextTick();
1094
+
1095
+ rendered.stdin.write("\n");
1096
+ await nextTick();
1097
+ await nextTick();
1098
+
1099
+ expect(opened).toBe(changeId);
1100
+ } finally {
1101
+ process.chdir(originalCwd);
1102
+ }
1103
+ });
1104
+
1105
+ test("status view renders all-tasks groups in task status order", () => {
1106
+ const originalCwd = process.cwd();
1107
+ const base = mkdtempSync(join(tmpdir(), "schub-status-all-tasks-groups-"));
1108
+ const schubDir = join(base, ".schub");
1109
+
1110
+ const tasks = [
1111
+ { status: "backlog", label: "Backlog", id: "T0701", slug: "zulu-backlog-task", title: "zulu backlog task" },
1112
+ { status: "backlog", label: "Backlog", id: "T0702", slug: "alpha-backlog-task", title: "alpha backlog task" },
1113
+ { status: "ready", label: "Ready", id: "T0703", slug: "ready-task", title: "ready task" },
1114
+ { status: "wip", label: "WIP", id: "T0704", slug: "wip-task", title: "wip task" },
1115
+ { status: "blocked", label: "Blocked", id: "T0705", slug: "blocked-task", title: "blocked task" },
1116
+ { status: "done", label: "Done", id: "T0706", slug: "done-task", title: "done task" },
1117
+ { status: "archived", label: "Archived", id: "T0707", slug: "archived-task", title: "archived task" },
1118
+ ] as const;
1119
+ const groupLabels = ["Backlog", "Ready", "WIP", "Blocked", "Done", "Archived"] as const;
1120
+
1121
+ for (const task of tasks) {
1122
+ const root = join(schubDir, "tasks", task.status);
1123
+ mkdirSync(root, { recursive: true });
1124
+ const body =
1125
+ task.status === "blocked"
1126
+ ? `---\nblocked_reason: ${task.title}\n---\n# Task: ${task.id} ${task.title}\n`
1127
+ : `# Task: ${task.id} ${task.title}\n`;
1128
+ writeTask(root, task.id, task.slug, body);
1129
+ }
1130
+
1131
+ try {
1132
+ process.chdir(base);
1133
+ const groups = buildShowAllTaskGroups(schubDir);
1134
+ const { lastFrame } = render(<StatusShowAllTasksView selection={0} showAllTaskGroups={groups} />);
1135
+ const output = stripAnsi(lastFrame() ?? "");
1136
+ const lines = output.split("\n");
1137
+
1138
+ const labelIndexes = groupLabels.map((label) => lines.findIndex((line) => line.trim() === label));
1139
+ for (const index of labelIndexes) {
1140
+ expect(index).toBeGreaterThanOrEqual(0);
1141
+ }
1142
+ for (let i = 1; i < labelIndexes.length; i += 1) {
1143
+ expect(labelIndexes[i]).toBeGreaterThan(labelIndexes[i - 1]);
1144
+ }
1145
+
1146
+ const backlogSection = extractSection(output, "Backlog", "Ready");
1147
+ const alphaIndex = backlogSection.indexOf("alpha backlog task");
1148
+ const zuluIndex = backlogSection.indexOf("zulu backlog task");
1149
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
1150
+ expect(zuluIndex).toBeGreaterThanOrEqual(0);
1151
+ expect(alphaIndex).toBeLessThan(zuluIndex);
1152
+
1153
+ for (const task of tasks) {
1154
+ const line = findLine(output, task.id);
1155
+ expect(line).toContain(task.title);
1156
+ }
1157
+ } finally {
1158
+ process.chdir(originalCwd);
1159
+ }
1160
+ });
1161
+
1162
+ test.skip("status view refreshes when proposals change", async () => {
1163
+ const originalCwd = process.cwd();
1164
+ const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-refresh-"));
1165
+ const schubDir = join(base, ".schub");
1166
+ const changesRoot = join(schubDir, "changes");
1167
+ const refreshIntervalMs = 50;
1168
+ let unmount: (() => void) | undefined;
1169
+
1170
+ mkdirSync(changesRoot, { recursive: true });
1171
+
1172
+ const changeId = "C0010_refresh-proposal";
1173
+ const displayId = changeDisplayId(changeId);
1174
+ writeProposal(changesRoot, changeId, "Draft");
1175
+
1176
+ try {
1177
+ process.chdir(base);
1178
+ const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
1179
+ unmount = rendered.unmount;
1180
+ const output = stripAnsi(rendered.lastFrame() ?? "");
1181
+
1182
+ expect(output).toContain("Drafts");
1183
+ expect(findLine(output, displayId)).toContain(displayId);
1184
+
1185
+ writeProposal(changesRoot, changeId, "Accepted");
1186
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
1187
+
1188
+ const refreshed = stripAnsi(rendered.lastFrame() ?? "");
1189
+ expect(refreshed).toContain("No Tasks Defined");
1190
+ expect(findLine(refreshed, displayId)).toContain(displayId);
1191
+ expect(refreshed).not.toContain("Drafts");
1192
+ } finally {
1193
+ unmount?.();
1194
+ process.chdir(originalCwd);
1195
+ }
1196
+ });
1197
+
1198
+ test.skip("status view clamps selection when tasks are removed", async () => {
1199
+ const originalCwd = process.cwd();
1200
+ const base = mkdtempSync(join(tmpdir(), "schub-status-selection-"));
1201
+ const schubDir = join(base, ".schub");
1202
+ const readyRoot = join(schubDir, "tasks", "ready");
1203
+ const refreshIntervalMs = 50;
1204
+ const selectionMarker = "\u203A";
1205
+ let unmount: (() => void) | undefined;
1206
+
1207
+ mkdirSync(readyRoot, { recursive: true });
1208
+ writeTask(readyRoot, "T0001", "alpha-task", "# Task: T0001 Alpha Task\n");
1209
+ writeTask(readyRoot, "T0002", "beta-task", "# Task: T0002 Beta Task\n");
1210
+
1211
+ try {
1212
+ process.chdir(base);
1213
+ const rendered = render(<StatusView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
1214
+ unmount = rendered.unmount;
1215
+ const { stdin } = rendered;
1216
+
1217
+ await new Promise((resolve) => setTimeout(resolve, 0));
1218
+ stdin.write("\u001B[B");
1219
+ await new Promise((resolve) => setTimeout(resolve, 20));
1220
+
1221
+ const moved = stripAnsi(rendered.lastFrame() ?? "");
1222
+ expect(hasSelectionMarker(moved, "T0002", selectionMarker)).toBe(true);
1223
+
1224
+ unlinkSync(join(readyRoot, "T0002_beta-task.md"));
1225
+ await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
1226
+
1227
+ const refreshed = stripAnsi(rendered.lastFrame() ?? "");
1228
+ expect(hasSelectionMarker(refreshed, "T0001", selectionMarker)).toBe(true);
1229
+ expect(refreshed).not.toContain("T0002");
1230
+ } finally {
1231
+ unmount?.();
1232
+ process.chdir(originalCwd);
1233
+ }
1234
+ });
1235
+
1236
+ test.skip("status view shows show-all proposals subpage and actions", async () => {
1237
+ const originalCwd = process.cwd();
1238
+ const base = mkdtempSync(join(tmpdir(), "schub-status-show-all-"));
1239
+ const schubDir = join(base, ".schub");
1240
+ const changesRoot = join(schubDir, "changes");
1241
+ const archiveRoot = join(schubDir, "archive", "changes");
1242
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
1243
+ let copied = "";
1244
+ const opened: string[] = [];
1245
+
1246
+ mkdirSync(changesRoot, { recursive: true });
1247
+ mkdirSync(archiveRoot, { recursive: true });
1248
+
1249
+ const draftAlpha = "C0001_alpha-proposal";
1250
+ const draftBravo = "C0002_bravo-proposal";
1251
+ const pendingReview = "C0003_pending-review";
1252
+ const accepted = "C0004_accepted";
1253
+ const implementing = "C0005_implementing";
1254
+ const done = "C0006_done";
1255
+ const archived = "C0007_archived";
1256
+
1257
+ writeProposal(changesRoot, draftAlpha, "Draft", "Alpha Proposal");
1258
+ writeProposal(changesRoot, draftBravo, "Draft", "Bravo Proposal");
1259
+ writeProposal(changesRoot, pendingReview, "Pending Review", "Pending Proposal");
1260
+ writeProposal(changesRoot, accepted, "Accepted", "Accepted Proposal");
1261
+ writeProposal(changesRoot, implementing, "Implementing", "Implementing Proposal");
1262
+ writeProposal(changesRoot, done, "Done", "Done Proposal");
1263
+ writeProposal(archiveRoot, archived, "Archived", "Archived Proposal");
1264
+
1265
+ try {
1266
+ process.chdir(base);
1267
+ const rendered = render(
1268
+ <StatusView
1269
+ onCopyId={(id) => {
1270
+ copied = id;
1271
+ }}
1272
+ onOpen={(_, path) => {
1273
+ opened.push(path);
1274
+ }}
1275
+ />,
1276
+ );
1277
+ const { stdin } = rendered;
1278
+
1279
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
1280
+ expect(initial).toContain("Show all proposals");
1281
+
1282
+ stdin.write("p");
1283
+ await new Promise((resolve) => setTimeout(resolve, 20));
1284
+
1285
+ const output = stripAnsi(rendered.lastFrame() ?? "");
1286
+ const groupLabels = ["Draft", "Pending Review", "Accepted", "Implementing", "Done", "Archived"];
1287
+ const groupIndexes = groupLabels.map((label) => output.indexOf(label));
1288
+ for (const index of groupIndexes) {
1289
+ expect(index).toBeGreaterThanOrEqual(0);
1290
+ }
1291
+ for (let i = 1; i < groupIndexes.length; i += 1) {
1292
+ expect(groupIndexes[i]).toBeGreaterThan(groupIndexes[i - 1]);
1293
+ }
1294
+
1295
+ const alphaIndex = output.indexOf(changeDisplayId(draftAlpha));
1296
+ const bravoIndex = output.indexOf(changeDisplayId(draftBravo));
1297
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
1298
+ expect(bravoIndex).toBeGreaterThan(alphaIndex);
1299
+ expect(output).toContain(changeDisplayId(archived));
1300
+
1301
+ stdin.write("c");
1302
+ await nextTick();
1303
+ expect(copied).toBe(draftAlpha);
1304
+
1305
+ stdin.write("o");
1306
+ await nextTick();
1307
+ expect(opened).toHaveLength(1);
1308
+ expect(opened[0]).toContain(`${draftAlpha}/proposal.md`);
1309
+ } finally {
1310
+ process.chdir(originalCwd);
1311
+ }
1312
+ });
1313
+
1314
+ test.skip("status view opens show-all proposals with enter and returns on escape", async () => {
1315
+ const originalCwd = process.cwd();
1316
+ const base = mkdtempSync(join(tmpdir(), "schub-status-show-all-enter-"));
1317
+ const schubDir = join(base, ".schub");
1318
+ const changesRoot = join(schubDir, "changes");
1319
+
1320
+ mkdirSync(changesRoot, { recursive: true });
1321
+
1322
+ const changeId = "C0008_enter-show-all";
1323
+ writeProposal(changesRoot, changeId, "Draft", "Enter Show All");
1324
+
1325
+ try {
1326
+ process.chdir(base);
1327
+ const rendered = render(<StatusView onCopyId={noopCopy} />);
1328
+ const { stdin } = rendered;
1329
+
1330
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
1331
+ expect(initial).toContain("Show all proposals");
1332
+
1333
+ await nextTick();
1334
+ stdin.write("\u001B[B");
1335
+ await new Promise((resolve) => setTimeout(resolve, 0));
1336
+ stdin.write("\n");
1337
+ await new Promise((resolve) => setTimeout(resolve, 20));
1338
+
1339
+ const subpage = stripAnsi(rendered.lastFrame() ?? "");
1340
+ expect(subpage).toContain("All Proposals · esc to return");
1341
+
1342
+ await new Promise((resolve) => setTimeout(resolve, 20));
1343
+ stdin.write("q");
1344
+ await new Promise((resolve) => setTimeout(resolve, 50));
1345
+
1346
+ const restored = stripAnsi(rendered.lastFrame() ?? "");
1347
+ expect(restored).toContain("Change Proposals");
1348
+ } finally {
1349
+ process.chdir(originalCwd);
1350
+ }
1351
+ });