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,519 @@
1
+ import { dirname } from "node:path";
2
+ import { Box, Text, useInput } from "ink";
3
+ import React from "react";
4
+ import { findSchubRoot, updateTaskStatuses } from "../../features/tasks";
5
+ import { launchOpencodeImplement } from "../../opencode";
6
+ import {
7
+ autoMarkChangesDone,
8
+ buildShowAllGroups,
9
+ buildShowAllTaskGroups,
10
+ buildStatusData,
11
+ isTaskItem,
12
+ } from "./status-view-data";
13
+ import {
14
+ isShowAllRow,
15
+ type ShowAllRow,
16
+ StatusMainView,
17
+ StatusShowAllTasksView,
18
+ StatusShowAllView,
19
+ } from "./status-view-render";
20
+
21
+ type Shortcut = {
22
+ keyLabel: string;
23
+ label: string;
24
+ };
25
+
26
+ type StatusViewProps = {
27
+ refreshIntervalMs?: number;
28
+ startDir?: string;
29
+ isActive?: boolean;
30
+ onCopyId: (id: string) => void;
31
+ onOpen?: (repoRoot: string, relativePath: string) => void;
32
+ onOpenDetail?: (changeId: string) => void;
33
+ onImplement?: (taskId: string, repoRoot: string) => void;
34
+ onShortcutsChange?: (shortcuts: Shortcut[]) => void;
35
+ onViewChange?: (view: "main" | "show-all" | "show-all-tasks") => void;
36
+ };
37
+
38
+ const DEFAULT_REFRESH_INTERVAL_MS = 1000;
39
+ const OPEN_SHORTCUT: Shortcut = { keyLabel: "o", label: "preview" };
40
+ const COPY_SHORTCUT: Shortcut = { keyLabel: "c", label: "copy id" };
41
+ const IMPLEMENT_SHORTCUT: Shortcut = { keyLabel: "i", label: "implement" };
42
+ const STATUS_SHORTCUT: Shortcut = { keyLabel: "s", label: "status" };
43
+
44
+ type StatusOption = {
45
+ label: string;
46
+ status: "backlog" | "ready" | "archived";
47
+ };
48
+
49
+ const BACKLOG_STATUS_OPTIONS: readonly StatusOption[] = [
50
+ { label: "Ready", status: "ready" },
51
+ { label: "Archive", status: "archived" },
52
+ ];
53
+
54
+ const READY_STATUS_OPTIONS: readonly StatusOption[] = [
55
+ { label: "Backlog", status: "backlog" },
56
+ { label: "Archive", status: "archived" },
57
+ ];
58
+
59
+ type StatusModalState = {
60
+ taskId: string;
61
+ selection: number;
62
+ options: readonly StatusOption[];
63
+ };
64
+
65
+ const SHOW_ALL_ROW: ShowAllRow = {
66
+ kind: "show-all",
67
+ id: "show-all-proposals",
68
+ title: "[Show all proposals]",
69
+ };
70
+
71
+ const SHOW_ALL_TASKS_ROW: ShowAllRow = {
72
+ kind: "show-all",
73
+ id: "show-all-tasks",
74
+ title: "[Show all tasks]",
75
+ };
76
+
77
+ const isShowAllTasksRow = (row: ShowAllRow) => row.id === SHOW_ALL_TASKS_ROW.id;
78
+
79
+ export default function StatusView({
80
+ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
81
+ startDir,
82
+ isActive = true,
83
+ onCopyId,
84
+ onOpen,
85
+ onOpenDetail,
86
+ onImplement = launchOpencodeImplement,
87
+ onShortcutsChange,
88
+ onViewChange,
89
+ }: StatusViewProps) {
90
+ const schubDir = findSchubRoot(startDir);
91
+ const [, setRefreshTick] = React.useState(0);
92
+ const {
93
+ pendingReview,
94
+ pendingImplementation,
95
+ pendingImplementationNoTasks,
96
+ drafts,
97
+ readyToImplement,
98
+ blocked,
99
+ wip,
100
+ ready,
101
+ backlog,
102
+ pendingImplementationCounts,
103
+ } = buildStatusData(schubDir);
104
+ const repoRoot = schubDir ? dirname(schubDir) : "";
105
+
106
+ const showAllGroups = buildShowAllGroups(schubDir);
107
+ const showAllItems = showAllGroups.flatMap((group) => group.items);
108
+ const showAllTaskGroups = buildShowAllTaskGroups(schubDir);
109
+ const showAllTaskItems = showAllTaskGroups.flatMap((group) => group.items);
110
+
111
+ const changeItems = [...pendingReview, ...pendingImplementation, ...pendingImplementationNoTasks, ...drafts];
112
+ const taskItems = [...readyToImplement, ...blocked, ...wip, ...ready, ...backlog];
113
+ const mainItems =
114
+ changeItems.length + taskItems.length > 0 ? [...changeItems, SHOW_ALL_ROW, ...taskItems, SHOW_ALL_TASKS_ROW] : [];
115
+ const mainSelectionFloor = changeItems.length === 0 && taskItems.length > 0 ? 1 : 0;
116
+
117
+ const [view, setView] = React.useState<"main" | "show-all" | "show-all-tasks">("main");
118
+ const [mainSelection, setMainSelection] = React.useState(() => mainSelectionFloor);
119
+ const [showAllSelection, setShowAllSelection] = React.useState(0);
120
+ const [showAllTasksSelection, setShowAllTasksSelection] = React.useState(0);
121
+ const [statusModal, setStatusModal] = React.useState<StatusModalState | null>(null);
122
+ const showAllShortcutRef = React.useRef(false);
123
+ const statusModalRef = React.useRef<StatusModalState | null>(null);
124
+
125
+ const updateView = (nextView: "main" | "show-all" | "show-all-tasks") => {
126
+ setView(nextView);
127
+ if (onViewChange) {
128
+ onViewChange(nextView);
129
+ }
130
+ };
131
+
132
+ const currentItems = view === "main" ? mainItems : view === "show-all" ? showAllItems : showAllTaskItems;
133
+ const selection = view === "main" ? mainSelection : view === "show-all" ? showAllSelection : showAllTasksSelection;
134
+ const setSelection =
135
+ view === "main" ? setMainSelection : view === "show-all" ? setShowAllSelection : setShowAllTasksSelection;
136
+ const totalItems = currentItems.length;
137
+
138
+ const selectedItem = totalItems > 0 ? currentItems[selection] : null;
139
+ const hasOpenableSelection = Boolean(selectedItem && !isShowAllRow(selectedItem));
140
+ const selectedTaskItem =
141
+ selectedItem && !isShowAllRow(selectedItem) && isTaskItem(selectedItem) ? selectedItem : null;
142
+ const canUpdateTaskStatus =
143
+ selectedTaskItem &&
144
+ (view === "main" || view === "show-all-tasks") &&
145
+ (selectedTaskItem.status === "backlog" || selectedTaskItem.status === "ready");
146
+ const selectedStatusTaskId = canUpdateTaskStatus ? selectedTaskItem.id : null;
147
+ const readyToImplementStart = changeItems.length + 1;
148
+ const readyToImplementEnd = readyToImplementStart + readyToImplement.length;
149
+ const readyToImplementIds = readyToImplement.map((task) => task.id);
150
+ const selectedReadyTaskId =
151
+ view === "main"
152
+ ? selection >= readyToImplementStart && selection < readyToImplementEnd
153
+ ? (readyToImplement[selection - readyToImplementStart]?.id ?? null)
154
+ : null
155
+ : view === "show-all-tasks" && selectedTaskItem && readyToImplementIds.includes(selectedTaskItem.id)
156
+ ? selectedTaskItem.id
157
+ : null;
158
+ const lastShortcutRef = React.useRef<string | null>(null);
159
+ const readyToImplementIdsRef = React.useRef(readyToImplementIds);
160
+ const readyToImplementRangeRef = React.useRef({ start: readyToImplementStart, end: readyToImplementEnd });
161
+ const currentItemsRef = React.useRef(currentItems);
162
+ const selectionRef = React.useRef(selection);
163
+ const viewRef = React.useRef(view);
164
+
165
+ React.useEffect(() => {
166
+ currentItemsRef.current = currentItems;
167
+ selectionRef.current = selection;
168
+ viewRef.current = view;
169
+ readyToImplementIdsRef.current = readyToImplement.map((task) => task.id);
170
+ readyToImplementRangeRef.current = { start: readyToImplementStart, end: readyToImplementEnd };
171
+ }, [currentItems, selection, view, readyToImplement, readyToImplementEnd, readyToImplementStart]);
172
+
173
+ React.useEffect(() => {
174
+ statusModalRef.current = statusModal;
175
+ }, [statusModal]);
176
+
177
+ React.useLayoutEffect(() => {
178
+ if (!onShortcutsChange) {
179
+ return;
180
+ }
181
+
182
+ const shortcuts: Shortcut[] = [];
183
+ if (hasOpenableSelection) {
184
+ shortcuts.push(OPEN_SHORTCUT, COPY_SHORTCUT);
185
+ }
186
+ if (selectedReadyTaskId) {
187
+ shortcuts.push(IMPLEMENT_SHORTCUT);
188
+ }
189
+ if (selectedStatusTaskId) {
190
+ shortcuts.push(STATUS_SHORTCUT);
191
+ }
192
+
193
+ const shortcutKey = shortcuts.map((shortcut) => shortcut.keyLabel).join("|") || "none";
194
+
195
+ if (lastShortcutRef.current === shortcutKey) {
196
+ return;
197
+ }
198
+
199
+ lastShortcutRef.current = shortcutKey;
200
+ onShortcutsChange(shortcuts);
201
+ }, [hasOpenableSelection, onShortcutsChange, selectedReadyTaskId, selectedStatusTaskId]);
202
+
203
+ React.useEffect(() => {
204
+ if (!schubDir) {
205
+ return;
206
+ }
207
+
208
+ const interval = setInterval(() => {
209
+ autoMarkChangesDone(schubDir);
210
+ setRefreshTick((current) => current + 1);
211
+ }, refreshIntervalMs);
212
+
213
+ return () => {
214
+ clearInterval(interval);
215
+ };
216
+ }, [refreshIntervalMs, schubDir]);
217
+
218
+ React.useEffect(() => {
219
+ if (mainItems.length === 0) {
220
+ setMainSelection(0);
221
+ return;
222
+ }
223
+ setMainSelection((current) => {
224
+ const clamped = Math.min(current, mainItems.length - 1);
225
+ return clamped === 0 ? mainSelectionFloor : clamped;
226
+ });
227
+ }, [mainItems.length, mainSelectionFloor]);
228
+
229
+ React.useEffect(() => {
230
+ if (showAllItems.length === 0) {
231
+ setShowAllSelection(0);
232
+ return;
233
+ }
234
+ setShowAllSelection((current) => Math.min(current, showAllItems.length - 1));
235
+ }, [showAllItems.length]);
236
+
237
+ React.useEffect(() => {
238
+ if (showAllTaskItems.length === 0) {
239
+ setShowAllTasksSelection(0);
240
+ return;
241
+ }
242
+ setShowAllTasksSelection((current) => Math.min(current, showAllTaskItems.length - 1));
243
+ }, [showAllTaskItems.length]);
244
+
245
+ React.useEffect(() => {
246
+ if (view) {
247
+ showAllShortcutRef.current = false;
248
+ }
249
+ }, [view]);
250
+
251
+ useInput(
252
+ (input, key) => {
253
+ const lowerInput = input.toLowerCase();
254
+ const keyName = (key as { name?: string }).name;
255
+ const keySequence = (key as { sequence?: string }).sequence;
256
+ const keyEnter = (key as { enter?: boolean }).enter;
257
+ const isEscape = key.escape || keyName === "escape" || keySequence === "\u001B" || input === "\u001B";
258
+ const isCtrlX =
259
+ (key.ctrl && (lowerInput === "x" || keyName === "x" || keySequence === "\u0018")) || input === "\u0018";
260
+ const isEnter =
261
+ key.return ||
262
+ keyEnter ||
263
+ keyName === "return" ||
264
+ keyName === "enter" ||
265
+ keySequence === "\r" ||
266
+ keySequence === "\n" ||
267
+ input === "\r" ||
268
+ input === "\n";
269
+ const isDownArrow = key.downArrow || Boolean(keySequence?.includes("[B")) || input.includes("[B");
270
+ const isUpArrow = key.upArrow || Boolean(keySequence?.includes("[A")) || input.includes("[A");
271
+ const isLeftArrow = key.leftArrow || Boolean(keySequence?.includes("[D")) || input.includes("[D");
272
+ const isRightArrow = key.rightArrow || Boolean(keySequence?.includes("[C")) || input.includes("[C");
273
+
274
+ const activeStatusModal = statusModalRef.current;
275
+ if (activeStatusModal) {
276
+ if (isEscape || lowerInput === "q") {
277
+ statusModalRef.current = null;
278
+ setStatusModal(null);
279
+ return;
280
+ }
281
+
282
+ if (isUpArrow || isLeftArrow) {
283
+ setStatusModal((current) => {
284
+ if (!current) {
285
+ return current;
286
+ }
287
+ const nextSelection = Math.max(0, current.selection - 1);
288
+ if (nextSelection === current.selection) {
289
+ return current;
290
+ }
291
+ const nextModal = { ...current, selection: nextSelection };
292
+ statusModalRef.current = nextModal;
293
+ return nextModal;
294
+ });
295
+ return;
296
+ }
297
+
298
+ if (isDownArrow || isRightArrow) {
299
+ setStatusModal((current) => {
300
+ if (!current) {
301
+ return current;
302
+ }
303
+ const nextSelection = Math.min(current.options.length - 1, current.selection + 1);
304
+ if (nextSelection === current.selection) {
305
+ return current;
306
+ }
307
+ const nextModal = { ...current, selection: nextSelection };
308
+ statusModalRef.current = nextModal;
309
+ return nextModal;
310
+ });
311
+ return;
312
+ }
313
+
314
+ if (isEnter) {
315
+ if (schubDir) {
316
+ const option = activeStatusModal.options[activeStatusModal.selection];
317
+ if (option) {
318
+ updateTaskStatuses(schubDir, [activeStatusModal.taskId], option.status);
319
+ setRefreshTick((current) => current + 1);
320
+ }
321
+ }
322
+ statusModalRef.current = null;
323
+ setStatusModal(null);
324
+ }
325
+
326
+ return;
327
+ }
328
+
329
+ const activeItems = currentItemsRef.current;
330
+ const activeSelection = selectionRef.current;
331
+ const activeItem = activeItems[activeSelection] ?? null;
332
+ const activeTotalItems = activeItems.length;
333
+ const activeView = viewRef.current;
334
+
335
+ if (activeView !== "main" && (isEscape || lowerInput === "q")) {
336
+ updateView("main");
337
+ return;
338
+ }
339
+
340
+ if (activeView === "main" && lowerInput === "t") {
341
+ updateView("show-all-tasks");
342
+ return;
343
+ }
344
+
345
+ if (activeView === "main" && input.includes("\u0018") && lowerInput.includes("p")) {
346
+ showAllShortcutRef.current = false;
347
+ updateView("show-all");
348
+ return;
349
+ }
350
+
351
+ if (activeView === "main") {
352
+ if (isCtrlX) {
353
+ showAllShortcutRef.current = true;
354
+ return;
355
+ }
356
+
357
+ if (showAllShortcutRef.current) {
358
+ showAllShortcutRef.current = false;
359
+ if (lowerInput === "p") {
360
+ updateView("show-all");
361
+ return;
362
+ }
363
+ }
364
+
365
+ if (lowerInput === "p") {
366
+ updateView("show-all");
367
+ return;
368
+ }
369
+ }
370
+
371
+ if (activeTotalItems === 0) {
372
+ return;
373
+ }
374
+
375
+ if (isDownArrow) {
376
+ const nextSelection = Math.min(activeSelection + 1, activeTotalItems - 1);
377
+ selectionRef.current = nextSelection;
378
+ setSelection(nextSelection);
379
+ }
380
+
381
+ if (isUpArrow) {
382
+ const nextSelection = Math.max(activeSelection - 1, 0);
383
+ selectionRef.current = nextSelection;
384
+ setSelection(nextSelection);
385
+ }
386
+
387
+ if (isEnter && activeItem && !isShowAllRow(activeItem) && !isTaskItem(activeItem)) {
388
+ onOpenDetail?.(activeItem.id);
389
+ return;
390
+ }
391
+
392
+ if (activeView === "main" && isEnter && activeItem && isShowAllRow(activeItem)) {
393
+ updateView(isShowAllTasksRow(activeItem) ? "show-all-tasks" : "show-all");
394
+ return;
395
+ }
396
+
397
+ if (input === "o" && activeItem && !isShowAllRow(activeItem)) {
398
+ onOpen?.(repoRoot, activeItem.path);
399
+ }
400
+
401
+ if (input === "c" && activeItem && !isShowAllRow(activeItem)) {
402
+ onCopyId(activeItem.id);
403
+ }
404
+
405
+ if (
406
+ lowerInput === "s" &&
407
+ (activeView === "main" || activeView === "show-all-tasks") &&
408
+ activeItem &&
409
+ !isShowAllRow(activeItem) &&
410
+ isTaskItem(activeItem) &&
411
+ (activeItem.status === "backlog" || activeItem.status === "ready")
412
+ ) {
413
+ const options = activeItem.status === "backlog" ? BACKLOG_STATUS_OPTIONS : READY_STATUS_OPTIONS;
414
+ const nextModal = { taskId: activeItem.id, selection: 0, options };
415
+ statusModalRef.current = nextModal;
416
+ setStatusModal(nextModal);
417
+ return;
418
+ }
419
+
420
+ if (input === "i") {
421
+ if (activeView === "main") {
422
+ const { start, end } = readyToImplementRangeRef.current;
423
+ if (activeSelection >= start && activeSelection < end) {
424
+ const readyIndex = activeSelection - start;
425
+ const taskId = readyToImplementIdsRef.current[readyIndex];
426
+ if (taskId) {
427
+ onImplement(taskId, repoRoot);
428
+ }
429
+ }
430
+ return;
431
+ }
432
+
433
+ if (
434
+ activeView === "show-all-tasks" &&
435
+ activeItem &&
436
+ !isShowAllRow(activeItem) &&
437
+ isTaskItem(activeItem) &&
438
+ readyToImplementIdsRef.current.includes(activeItem.id)
439
+ ) {
440
+ onImplement(activeItem.id, repoRoot);
441
+ }
442
+ }
443
+ },
444
+ { isActive },
445
+ );
446
+
447
+ if (!schubDir) {
448
+ return (
449
+ <Box flexDirection="column">
450
+ <Text bold>Status</Text>
451
+ <Text color="red">No .schub directory found.</Text>
452
+ </Box>
453
+ );
454
+ }
455
+
456
+ if (view === "main" && mainItems.length === 0) {
457
+ return (
458
+ <Box flexDirection="column">
459
+ <Text color="gray">No active changes or tasks found.</Text>
460
+ </Box>
461
+ );
462
+ }
463
+
464
+ if (view === "show-all") {
465
+ return <StatusShowAllView selection={selection} showAllGroups={showAllGroups} />;
466
+ }
467
+
468
+ const content =
469
+ view === "show-all-tasks" ? (
470
+ <StatusShowAllTasksView selection={selection} showAllTaskGroups={showAllTaskGroups} />
471
+ ) : (
472
+ <StatusMainView
473
+ selection={selection}
474
+ pendingReview={pendingReview}
475
+ pendingImplementation={pendingImplementation}
476
+ pendingImplementationNoTasks={pendingImplementationNoTasks}
477
+ drafts={drafts}
478
+ pendingImplementationCounts={pendingImplementationCounts}
479
+ readyToImplement={readyToImplement}
480
+ blocked={blocked}
481
+ wip={wip}
482
+ ready={ready}
483
+ backlog={backlog}
484
+ showAllRow={SHOW_ALL_ROW}
485
+ showAllTasksRow={SHOW_ALL_TASKS_ROW}
486
+ />
487
+ );
488
+
489
+ return (
490
+ <Box flexDirection="column">
491
+ {content}
492
+ {statusModal ? (
493
+ <Box backgroundColor="darkGray" flexDirection="column" paddingX={2} paddingY={1}>
494
+ <Box flexDirection="column" marginBottom={1}>
495
+ {statusModal.options.map((option, index) => {
496
+ const selected = index === statusModal.selection;
497
+ return (
498
+ <Box key={option.status}>
499
+ <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
500
+ <Text color="white"> {option.label}</Text>
501
+ </Box>
502
+ );
503
+ })}
504
+ </Box>
505
+ <Box alignItems="flex-end">
506
+ <Box marginRight={2}>
507
+ <Text color="white">enter</Text>
508
+ <Text color="gray"> confirm</Text>
509
+ </Box>
510
+ <Box>
511
+ <Text color="white">esc</Text>
512
+ <Text color="gray"> cancel</Text>
513
+ </Box>
514
+ </Box>
515
+ </Box>
516
+ ) : null}
517
+ </Box>
518
+ );
519
+ }
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "bun:test";
2
+ import { renderMarkdownLines } from "./markdown-renderer";
3
+
4
+ test("renderMarkdownLines styles headings", () => {
5
+ const lines = renderMarkdownLines("# Heading");
6
+
7
+ expect(lines).toEqual([
8
+ {
9
+ segments: [{ text: "Heading", color: "#FFA500", bold: true }],
10
+ },
11
+ ]);
12
+ });
13
+
14
+ test("renderMarkdownLines styles bold segments", () => {
15
+ const lines = renderMarkdownLines("Hello **World** and **Friends**");
16
+
17
+ expect(lines).toEqual([
18
+ {
19
+ segments: [{ text: "Hello " }, { text: "World", bold: true }, { text: " and " }, { text: "Friends", bold: true }],
20
+ },
21
+ ]);
22
+ });
23
+
24
+ test("renderMarkdownLines styles fenced code blocks", () => {
25
+ const markdown = "```ts\nconst value = **bold**\nconst total = 42\n```";
26
+ const lines = renderMarkdownLines(markdown);
27
+
28
+ expect(lines).toEqual([
29
+ {
30
+ segments: [{ text: "const value = **bold**", color: "green" }],
31
+ },
32
+ {
33
+ segments: [{ text: "const total = 42", color: "green" }],
34
+ },
35
+ ]);
36
+ });
37
+
38
+ test("renderMarkdownLines styles block quotes", () => {
39
+ const lines = renderMarkdownLines("> quoted line");
40
+
41
+ expect(lines).toEqual([
42
+ {
43
+ segments: [{ text: "quoted line", color: "white", backgroundColor: "#444444" }],
44
+ },
45
+ ]);
46
+ });
@@ -0,0 +1,89 @@
1
+ export type MarkdownSegment = {
2
+ text: string;
3
+ color?: string;
4
+ backgroundColor?: string;
5
+ bold?: boolean;
6
+ };
7
+
8
+ export type MarkdownLine = {
9
+ segments: MarkdownSegment[];
10
+ };
11
+
12
+ const headingPattern = /^#{1,6}\s*(.*)$/;
13
+ const blockQuotePattern = /^>\s?(.*)$/;
14
+ const codeFencePattern = /^\s*```/;
15
+ const headingStyle = { color: "#FFA500", bold: true } as const;
16
+ const blockQuoteStyle = { color: "white", backgroundColor: "#444444" } as const;
17
+ const codeStyle = { color: "green" } as const;
18
+
19
+ const parseBoldSegments = (line: string) => {
20
+ if (line.length === 0) {
21
+ return [{ text: "" }];
22
+ }
23
+
24
+ const segments: MarkdownSegment[] = [];
25
+ let cursor = 0;
26
+ let isBold = false;
27
+
28
+ while (cursor < line.length) {
29
+ const markerIndex = line.indexOf("**", cursor);
30
+
31
+ if (markerIndex === -1) {
32
+ const remainder = line.slice(cursor);
33
+ if (remainder.length > 0 || segments.length === 0) {
34
+ segments.push(isBold ? { text: remainder, bold: true } : { text: remainder });
35
+ }
36
+ break;
37
+ }
38
+
39
+ if (markerIndex > cursor) {
40
+ const text = line.slice(cursor, markerIndex);
41
+ segments.push(isBold ? { text, bold: true } : { text });
42
+ }
43
+
44
+ isBold = !isBold;
45
+ cursor = markerIndex + 2;
46
+ }
47
+
48
+ if (segments.length === 0) {
49
+ segments.push({ text: "" });
50
+ }
51
+
52
+ return segments;
53
+ };
54
+
55
+ export const renderMarkdownLines = (markdown: string) => {
56
+ const lines: MarkdownLine[] = [];
57
+ const rawLines = markdown.split(/\r?\n/);
58
+ let inCodeBlock = false;
59
+
60
+ for (const rawLine of rawLines) {
61
+ if (codeFencePattern.test(rawLine)) {
62
+ inCodeBlock = !inCodeBlock;
63
+ continue;
64
+ }
65
+
66
+ if (inCodeBlock) {
67
+ lines.push({ segments: [{ text: rawLine, ...codeStyle }] });
68
+ continue;
69
+ }
70
+
71
+ const blockQuoteMatch = rawLine.match(blockQuotePattern);
72
+ if (blockQuoteMatch) {
73
+ const quoteText = blockQuoteMatch[1];
74
+ lines.push({ segments: [{ text: quoteText, ...blockQuoteStyle }] });
75
+ continue;
76
+ }
77
+
78
+ const headingMatch = rawLine.match(headingPattern);
79
+ if (headingMatch) {
80
+ const headingText = headingMatch[1].trim();
81
+ lines.push({ segments: [{ text: headingText, ...headingStyle }] });
82
+ continue;
83
+ }
84
+
85
+ lines.push({ segments: parseBoldSegments(rawLine) });
86
+ }
87
+
88
+ return lines;
89
+ };