tuiboard 0.5.0

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 (41) hide show
  1. package/.tuiboard/config.example.yaml +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -0
  4. package/bin/tuiboard.ts +28 -0
  5. package/package.json +62 -0
  6. package/src/app.tsx +129 -0
  7. package/src/cli/args.test.ts +40 -0
  8. package/src/cli/args.ts +41 -0
  9. package/src/config/loader.ts +169 -0
  10. package/src/input/handleKey.ts +733 -0
  11. package/src/io/watcher.ts +85 -0
  12. package/src/io/writer.ts +92 -0
  13. package/src/parser/markdown.ts +351 -0
  14. package/src/parser/serialize.ts +97 -0
  15. package/src/scripts/agents-check.ts +24 -0
  16. package/src/scripts/parse-check.ts +124 -0
  17. package/src/scripts/roundtrip-check.ts +79 -0
  18. package/src/store/agents.test.ts +181 -0
  19. package/src/store/agents.ts +435 -0
  20. package/src/store/index.test.ts +110 -0
  21. package/src/store/index.ts +972 -0
  22. package/src/store/parsers.ts +243 -0
  23. package/src/store/timeline.test.ts +279 -0
  24. package/src/store/timeline.ts +279 -0
  25. package/src/store/virtual-panel.ts +0 -0
  26. package/src/types.ts +116 -0
  27. package/src/ui/AgentRow.tsx +79 -0
  28. package/src/ui/AgentsBar.tsx +102 -0
  29. package/src/ui/BoardView.tsx +333 -0
  30. package/src/ui/Chrome.tsx +122 -0
  31. package/src/ui/Modal.tsx +613 -0
  32. package/src/ui/TaskRow.tsx +240 -0
  33. package/src/ui/TimelineView.tsx +643 -0
  34. package/src/ui/VirtualPanel.tsx +237 -0
  35. package/src/ui/board-scroll.test.ts +63 -0
  36. package/src/ui/board-scroll.ts +49 -0
  37. package/src/ui/glyphs.ts +129 -0
  38. package/src/views/AgentsOnly.tsx +103 -0
  39. package/src/views/BoardOnly.tsx +35 -0
  40. package/src/views/Dashboard.tsx +106 -0
  41. package/src/views/TimelineOnly.tsx +12 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Kanban board view — horizontal scrolling row of full-height columns.
3
+ *
4
+ * Mirrors the Today/Tomorrow virtual panel: each column has a fixed width
5
+ * and stretches vertically to fill the board area. Columns that don't fit
6
+ * the available horizontal space are reached via horizontal scroll. Zoom
7
+ * mode collapses everything down to the single active column at full
8
+ * board width.
9
+ */
10
+
11
+ import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
12
+
13
+ import { isHiddenColumn } from "~/config/loader";
14
+ import { isTask } from "~/parser/markdown";
15
+ import { computeColumnScrollLeft } from "~/ui/board-scroll";
16
+ import { T } from "~/ui/glyphs";
17
+ import { TaskRow } from "~/ui/TaskRow";
18
+ import type { TuiStore } from "~/store/index";
19
+ import type { Board, Column } from "~/types";
20
+
21
+ // Minimal structural typing for the scrollbox ref so we don't depend on
22
+ // importing OpenTUI's internal Renderable types just to call its methods.
23
+ interface ScrollBoxLike {
24
+ scrollChildIntoView(id: string): void;
25
+ }
26
+
27
+ /** Just enough of a box renderable to read its laid-out width. */
28
+ interface SizedBoxLike {
29
+ readonly width: number;
30
+ }
31
+
32
+ /** Fixed column width when not zoomed. Single source of truth for layout. */
33
+ const COL_WIDTH = 42;
34
+ /**
35
+ * Width of a collapsed column — one with no OPEN tasks (an all-done lane like
36
+ * "Done", or an empty column). It shows just the `✓ N` counter; zoom (`z`)
37
+ * expands it back to full width so the done tasks can be scrolled on demand.
38
+ */
39
+ const COL_WIDTH_COLLAPSED = 18;
40
+ /** Horizontal gap between adjacent columns. */
41
+ const COL_GAP = 1;
42
+
43
+ /** Open (non-done) task count for a column, honoring the active board filter. */
44
+ function openCountOf(store: TuiStore, column: Column): number {
45
+ return store.applyBoardFilter(
46
+ column.children.filter(isTask).filter((t) => !t.done),
47
+ ).length;
48
+ }
49
+
50
+ interface BoardViewProps {
51
+ store: TuiStore;
52
+ board: Board;
53
+ }
54
+
55
+ /** Stable id for the column box at original board index `idx`. */
56
+ function columnId(boardPath: string, idx: number): string {
57
+ // boardPath is encoded so cross-board column ids don't collide if we
58
+ // ever render multiple boards side-by-side in the future.
59
+ return `tuiboard-col-${boardPath.replace(/[^a-zA-Z0-9]/g, "_")}-${idx}`;
60
+ }
61
+
62
+ export function BoardView(props: BoardViewProps) {
63
+ const ui = () => props.store.state.ui;
64
+ // Width of the clipping viewport (the board zone), read from layout.
65
+ let viewportRef: SizedBoxLike | undefined;
66
+ // Horizontal scroll offset in cells, applied as a negative left margin on
67
+ // the inner column row. A signal so the shift re-renders reactively.
68
+ const [scrollX, setScrollX] = createSignal(0);
69
+
70
+ /**
71
+ * Columns shown in the view — the Done and Archive columns are filtered
72
+ * out entirely (completed-work logs; never displayed on the board). Their
73
+ * tasks still live in the model so tasks can be moved into them; we just
74
+ * don't render them.
75
+ */
76
+ const visibleColumns = createMemo(() =>
77
+ props.board.columns.filter((c) => !isHiddenColumn(props.store.config, c.name)),
78
+ );
79
+
80
+ /**
81
+ * In zoom mode we render only the column under the cursor, expanded
82
+ * to fill the board area. This is the "focus on one column" mode
83
+ * (Python kanban `z`).
84
+ */
85
+ const renderedColumns = createMemo(() => {
86
+ if (!ui().zoomed || ui().activeZone === "virtual") return visibleColumns();
87
+ const cols = visibleColumns();
88
+ // ui.col is a board.columns index (carries Archive); map it to the
89
+ // rendered list so zoom focuses the column actually under the cursor.
90
+ const boardCols = props.board.columns;
91
+ const visibleIndex = cols.findIndex(
92
+ (c) => boardCols.indexOf(c) === ui().col,
93
+ );
94
+ const idx = visibleIndex >= 0 ? visibleIndex : Math.min(ui().col, cols.length - 1);
95
+ return idx >= 0 ? [cols[idx]!] : cols;
96
+ });
97
+
98
+ // Auto-scroll horizontally so the active column is always fully visible.
99
+ //
100
+ // We do this MANUALLY rather than with OpenTUI's <scrollbox>: on the
101
+ // horizontal axis the scrollbox's content box never grows past the
102
+ // viewport width (scrollWidth stays == viewportWidth), so it reports
103
+ // "nothing to scroll" even when fixed-width columns overflow. Instead we
104
+ // clip with a plain overflow:hidden box and shift an inner row left by a
105
+ // negative margin. The shift amount comes from the same deterministic
106
+ // geometry used everywhere (COL_WIDTH + COL_GAP).
107
+ createEffect(() => {
108
+ const colIdx = ui().col;
109
+ if (ui().zoomed || ui().activeZone === "virtual") {
110
+ setScrollX(0);
111
+ return;
112
+ }
113
+ const cols = props.board.columns;
114
+ const visibleIndex = visibleColumns().findIndex(
115
+ (c) => cols.indexOf(c) === colIdx,
116
+ );
117
+ if (visibleIndex < 0) return;
118
+ // Columns are uniform width today, so the active column's start offset is
119
+ // visibleIndex * stride. Passing an explicit start/width (rather than an
120
+ // index) keeps the geometry correct if columns ever become variable-width.
121
+ const colStart = visibleIndex * (COL_WIDTH + COL_GAP);
122
+ // setTimeout(0) lets OpenTUI commit layout so viewportRef.width is current.
123
+ setTimeout(() => {
124
+ const vw = viewportRef?.width ?? 0;
125
+ if (vw <= 0) return;
126
+ setScrollX((prev) =>
127
+ computeColumnScrollLeft({
128
+ colStart,
129
+ colWidth: COL_WIDTH,
130
+ viewportWidth: vw,
131
+ currentScroll: prev,
132
+ }),
133
+ );
134
+ }, 0);
135
+ });
136
+
137
+ return (
138
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
139
+ {/* Clipping viewport: fills the board zone, hides overflow. */}
140
+ <box
141
+ ref={(r: SizedBoxLike) => (viewportRef = r)}
142
+ style={{
143
+ width: "100%",
144
+ flexGrow: 1,
145
+ flexDirection: "row",
146
+ overflow: "hidden",
147
+ }}
148
+ >
149
+ {/* Inner row: shifted left by the scroll offset to reveal columns. */}
150
+ <box
151
+ style={{
152
+ flexDirection: "row",
153
+ flexGrow: ui().zoomed ? 1 : 0,
154
+ flexShrink: 0,
155
+ height: "100%",
156
+ alignItems: "stretch",
157
+ marginLeft: ui().zoomed ? 0 : -scrollX(),
158
+ }}
159
+ >
160
+ <For each={renderedColumns()}>
161
+ {(col) => {
162
+ const originalIndex = props.board.columns.indexOf(col);
163
+ const isActive = () =>
164
+ ui().activeZone === "board" && ui().col === originalIndex;
165
+ return (
166
+ <ColumnView
167
+ store={props.store}
168
+ board={props.board}
169
+ column={col}
170
+ columnIndex={originalIndex}
171
+ active={isActive()}
172
+ zoomed={ui().zoomed && isActive()}
173
+ boxId={columnId(props.board.filepath, originalIndex)}
174
+ />
175
+ );
176
+ }}
177
+ </For>
178
+ </box>
179
+ </box>
180
+ </box>
181
+ );
182
+ }
183
+
184
+ interface ColumnViewProps {
185
+ store: TuiStore;
186
+ board: Board;
187
+ column: Column;
188
+ columnIndex: number;
189
+ active: boolean;
190
+ /**
191
+ * When true, the column was zoomed to full width — done tasks are
192
+ * shown inline because the user has explicitly focused this column.
193
+ */
194
+ zoomed: boolean;
195
+ /** Stable DOM-equivalent id used by `scrollChildIntoView`. */
196
+ boxId: string;
197
+ }
198
+
199
+ /** Stable id for a task row inside a column, used by scrollChildIntoView. */
200
+ function taskRowId(boardPath: string, colIdx: number, rowIdx: number): string {
201
+ return `tuiboard-task-${boardPath.replace(/[^a-zA-Z0-9]/g, "_")}-${colIdx}-${rowIdx}`;
202
+ }
203
+
204
+ function ColumnView(props: ColumnViewProps) {
205
+ const allTasks = createMemo(() => props.column.children.filter(isTask));
206
+ const openTasks = createMemo(() =>
207
+ props.store.applyBoardFilter(allTasks().filter((t) => !t.done)),
208
+ );
209
+ const doneTasks = createMemo(() => allTasks().filter((t) => t.done));
210
+
211
+ // In zoom mode, show open tasks first, then a divider, then done tasks.
212
+ // In normal mode, show only open tasks; done collapse to a counter.
213
+ const visibleTasks = createMemo(() => {
214
+ if (props.zoomed) return [...openTasks(), ...doneTasks()];
215
+ return openTasks();
216
+ });
217
+
218
+ const cursorRow = createMemo(() => props.store.state.ui.row);
219
+
220
+ // Auto-scroll the column's inner scrollbox so the cursor row is always in
221
+ // view. Without this, OpenTUI's scrollbox didn't know to follow the
222
+ // cursor — user reported pressing arrow down and seeing the cursor stuck
223
+ // until the visible window happened to catch up. setTimeout(0) waits for
224
+ // OpenTUI to commit layout before the scroll, same pattern as BoardView's
225
+ // column auto-scroll and TimelineView's now-line auto-scroll.
226
+ let innerScrollBoxRef: ScrollBoxLike | undefined;
227
+ createEffect(() => {
228
+ if (!props.active) return;
229
+ const row = cursorRow();
230
+ if (!innerScrollBoxRef) return;
231
+ setTimeout(() => {
232
+ try {
233
+ innerScrollBoxRef?.scrollChildIntoView(
234
+ taskRowId(props.board.filepath, props.columnIndex, row),
235
+ );
236
+ } catch {
237
+ // Child not mounted yet — harmless.
238
+ }
239
+ }, 0);
240
+ });
241
+
242
+ const titleText = () => {
243
+ const zoomMark = props.zoomed ? "⤢ " : "";
244
+ let s = `┤ ${zoomMark}${props.column.name} ${openTasks().length}`;
245
+ if (doneTasks().length > 0) s += ` ✓${doneTasks().length}`;
246
+ return s + " ├";
247
+ };
248
+
249
+ return (
250
+ <box
251
+ id={props.boxId}
252
+ style={{
253
+ flexDirection: "column",
254
+ // Fixed width when not zoomed; the zoomed column grows to fill
255
+ // whatever horizontal space the board zone has been given.
256
+ width: props.zoomed ? undefined : COL_WIDTH,
257
+ minWidth: props.zoomed ? undefined : COL_WIDTH,
258
+ flexGrow: props.zoomed ? 1 : 0,
259
+ flexShrink: 0,
260
+ // No explicit height — Yoga stretches us along the row's cross
261
+ // axis, so the column always fills the full board height. Same
262
+ // contract as the Today/Tomorrow virtual panel next door.
263
+ marginRight: COL_GAP,
264
+ border: true,
265
+ borderStyle: "rounded",
266
+ borderColor: props.active ? T.borderActive : T.border,
267
+ paddingLeft: 1,
268
+ paddingRight: 1,
269
+ }}
270
+ title={titleText()}
271
+ titleAlignment="left"
272
+ >
273
+ <scrollbox
274
+ ref={(r: ScrollBoxLike) => (innerScrollBoxRef = r)}
275
+ style={{
276
+ width: "100%",
277
+ flexGrow: 1,
278
+ rootOptions: {},
279
+ contentOptions: {},
280
+ scrollbarOptions: { visible: false },
281
+ }}
282
+ >
283
+ <For each={visibleTasks()}>
284
+ {(task, ri) => {
285
+ const ref = {
286
+ boardPath: props.board.filepath,
287
+ columnIndex: props.columnIndex,
288
+ taskIndex: allTasks().indexOf(task),
289
+ };
290
+ return (
291
+ <box id={taskRowId(props.board.filepath, props.columnIndex, ri())}>
292
+ <TaskRow
293
+ task={task}
294
+ cursor={props.active && ri() === cursorRow()}
295
+ marked={props.store.isMarked(ref)}
296
+ grabbed={
297
+ props.active &&
298
+ ri() === cursorRow() &&
299
+ props.store.state.ui.grabbing
300
+ }
301
+ // Column inner cell width for a TaskRow: COL_WIDTH 42 −
302
+ // border 2 − col padding 2 − TaskRow padding 2 = 36 cols
303
+ // (when not zoomed). Zoomed → column grows to fill, so
304
+ // ~terminal width − some chrome.
305
+ availableWidth={props.zoomed ? 100 : 36}
306
+ onClick={() => {
307
+ props.store.setActiveZone("board");
308
+ props.store.setCursor(props.columnIndex, ri());
309
+ }}
310
+ />
311
+ </box>
312
+ );
313
+ }}
314
+ </For>
315
+
316
+ <Show when={!props.zoomed && doneTasks().length > 0}>
317
+ <box
318
+ style={{
319
+ flexDirection: "row",
320
+ marginTop: 1,
321
+ }}
322
+ >
323
+ <text wrapMode="none" truncate>
324
+ <span style={{ fg: T.textDim }}>
325
+ {"✓ "}{doneTasks().length}{" done (z to focus)"}
326
+ </span>
327
+ </text>
328
+ </box>
329
+ </Show>
330
+ </scrollbox>
331
+ </box>
332
+ );
333
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared chrome — top tab bar (boards + brand + stats) and bottom keybar
3
+ * (banner + shortcut hint line). Used by every root view so the user
4
+ * always sees the same orientation regardless of --view=X mode.
5
+ */
6
+
7
+ import { For, Show } from "solid-js";
8
+
9
+ import { isHiddenColumn } from "~/config/loader";
10
+ import { isoToday } from "~/store/index";
11
+ import { ATTR, T } from "~/ui/glyphs";
12
+ import type { TuiStore } from "~/store/index";
13
+
14
+ export function TopBar(props: { store: TuiStore }) {
15
+ const boards = () => props.store.state.boards;
16
+ const active = () => props.store.state.ui.activeBoardIndex;
17
+ const activeStats = () => {
18
+ const b = boards()[active()]?.board;
19
+ if (!b) return undefined;
20
+ let open = 0, done = 0, cols = 0;
21
+ for (const c of b.columns) {
22
+ if (!isHiddenColumn(props.store.config, c.name)) cols++;
23
+ for (const child of c.children) {
24
+ if (!("kind" in child)) {
25
+ if (child.done) done++;
26
+ else open++;
27
+ }
28
+ }
29
+ }
30
+ return { open, done, cols };
31
+ };
32
+
33
+ // Build a flat token list for the tab row so we can render as a single
34
+ // <text> without JSX fragments (which OpenTUI's Solid renderer doesn't
35
+ // play well with inside <text>).
36
+ const tabsText = () => {
37
+ const parts: Array<{ text: string; active: boolean; brand?: boolean }> = [];
38
+ parts.push({ text: "tuiboard", active: false, brand: true });
39
+ parts.push({ text: ` ${isoToday()} `, active: false });
40
+ boards().forEach((b: { board: { name: string } }, i: number) => {
41
+ const isActive = i === active();
42
+ parts.push({
43
+ text: isActive ? `[${i + 1} ${b.board.name}]` : ` ${i + 1} ${b.board.name} `,
44
+ active: isActive,
45
+ });
46
+ parts.push({ text: " ", active: false });
47
+ });
48
+ return parts;
49
+ };
50
+
51
+ return (
52
+ <box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
53
+ <text wrapMode="none" truncate style={{ flexGrow: 1, flexShrink: 1 }}>
54
+ <For each={tabsText()}>
55
+ {(p) => (
56
+ <span
57
+ style={{
58
+ fg: p.brand
59
+ ? T.accent
60
+ : p.active
61
+ ? T.accent
62
+ : T.textDim,
63
+ attributes: p.brand || p.active ? ATTR.bold : 0,
64
+ }}
65
+ >
66
+ {p.text}
67
+ </span>
68
+ )}
69
+ </For>
70
+ </text>
71
+ <Show when={activeStats()}>
72
+ <text wrapMode="none" style={{ flexShrink: 0, marginLeft: 2 }}>
73
+ <span style={{ fg: T.textDim }}>
74
+ {activeStats()!.open} open · {activeStats()!.done} done · {activeStats()!.cols} cols
75
+ </span>
76
+ </text>
77
+ </Show>
78
+ </box>
79
+ );
80
+ }
81
+
82
+ export function BottomBar(props: { store: TuiStore }) {
83
+ const banner = () => props.store.state.ui.banner;
84
+ return (
85
+ <box style={{ flexDirection: "column", marginTop: 1 }}>
86
+ <box style={{ height: 1, flexDirection: "row" }}>
87
+ <Show
88
+ when={banner()}
89
+ fallback={
90
+ <text>
91
+ <span style={{ fg: T.textDim }}>{" "}</span>
92
+ </text>
93
+ }
94
+ >
95
+ {(b: () => NonNullable<ReturnType<typeof banner>>) => (
96
+ <text>
97
+ <span
98
+ style={{
99
+ fg:
100
+ b().kind === "error"
101
+ ? T.bannerError
102
+ : b().kind === "warn"
103
+ ? T.bannerWarn
104
+ : T.bannerInfo,
105
+ }}
106
+ >
107
+ {"⚑ "}{b().text}
108
+ </span>
109
+ </text>
110
+ )}
111
+ </Show>
112
+ </box>
113
+ <box style={{ height: 1, flexDirection: "row" }}>
114
+ <text>
115
+ <span style={{ fg: T.textDim }}>
116
+ {"hjkl move · Tab/1-9 board · S-Tab zone · F1/F2/F3 toggle · v panel · z zoom · Space mark · ⏎ done · o detail · n/e/s/b/a/X act · d del · ⌃Z undo · ? help · q quit"}
117
+ </span>
118
+ </text>
119
+ </box>
120
+ </box>
121
+ );
122
+ }