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,237 @@
1
+ /** The fixed Today/Tomorrow virtual panel — always on the left. */
2
+
3
+ import { For, Show, createEffect, createMemo } from "solid-js";
4
+
5
+ import { ATTR, T, boardColor } from "~/ui/glyphs";
6
+ import { TaskRow } from "~/ui/TaskRow";
7
+ import {
8
+ buildVirtualItems,
9
+ groupVirtualItems,
10
+ type VirtualGroup,
11
+ } from "~/store/virtual-panel";
12
+ import type { TuiStore } from "~/store/index";
13
+
14
+ interface ScrollBoxLike {
15
+ scrollChildIntoView(id: string): void;
16
+ }
17
+
18
+ const VP_ROW_PREFIX = "tuiboard-vp-row-";
19
+ const vpRowId = (flatIndex: number) => `${VP_ROW_PREFIX}${flatIndex}`;
20
+
21
+ const SECTION_HEADER: Record<string, { label: string; color: string }> = {
22
+ overdue: { label: "● Overdue", color: T.overdue },
23
+ today: { label: "● Today", color: T.today },
24
+ tomorrow: { label: "→ Tomorrow", color: T.warmDim },
25
+ };
26
+
27
+ // Only the agenda and priority buckets get a header. The "rest" bucket has
28
+ // no header of its own — its items already sit under their own
29
+ // `— board · column —` sub-dividers, so a generic label would be redundant.
30
+ const BUCKET_HEADER: Record<string, { label: string; color: string }> = {
31
+ agenda: { label: "⏰ Agenda", color: T.accent },
32
+ priority: { label: "🔺 Priority", color: T.highest },
33
+ };
34
+
35
+ export function VirtualPanel(props: { store: TuiStore }) {
36
+ const items = createMemo(() => {
37
+ return buildVirtualItems(props.store.state.boards.map((b) => b.board));
38
+ });
39
+ const groups = createMemo(() => groupVirtualItems(items()));
40
+ const isActive = createMemo(() => props.store.state.ui.activeZone === "virtual");
41
+ const isZoomed = createMemo(
42
+ () => props.store.state.ui.zoomed && props.store.state.ui.activeZone === "virtual",
43
+ );
44
+ const cursorRow = createMemo(() => props.store.state.ui.row);
45
+ let scrollBoxRef: ScrollBoxLike | undefined;
46
+
47
+ // Auto-scroll so the cursor's row stays visible. Without this, when the
48
+ // panel's content overflows, pressing j/k advanced ui.row but the
49
+ // scrollbox didn't follow — the cursor moved invisibly until the bottom
50
+ // of the visible window happened to scroll past it. Now the scroll
51
+ // tracks the cursor on every move (BoardView pattern).
52
+ createEffect(() => {
53
+ const row = cursorRow();
54
+ if (!isActive() || !scrollBoxRef) return;
55
+ setTimeout(() => {
56
+ try {
57
+ scrollBoxRef?.scrollChildIntoView(vpRowId(row));
58
+ } catch {
59
+ // Child not mounted yet — harmless.
60
+ }
61
+ }, 0);
62
+ });
63
+
64
+ // Decorate title with vertical "tabs" `┤ … ├` so it visually breaks
65
+ // through the rounded border line (Superfile-style).
66
+ const titleText = () =>
67
+ `┤ ${isZoomed() ? "⤢ " : ""}Today / Tomorrow ${items().length} ├`;
68
+
69
+ return (
70
+ <box
71
+ style={{
72
+ flexDirection: "column",
73
+ width: isZoomed() ? undefined : 38,
74
+ minWidth: isZoomed() ? undefined : 38,
75
+ flexGrow: isZoomed() ? 1 : 0,
76
+ marginRight: 1,
77
+ border: true,
78
+ borderStyle: "rounded",
79
+ // Today/Tomorrow panel keeps its warm identity at all times — when
80
+ // focused it brightens, otherwise it dims, but never goes cool.
81
+ borderColor: isActive() ? T.warmActive : T.warm,
82
+ paddingLeft: 1,
83
+ paddingRight: 1,
84
+ }}
85
+ title={titleText()}
86
+ titleAlignment="left"
87
+ >
88
+ <Show
89
+ when={items().length > 0}
90
+ fallback={
91
+ <text>
92
+ <span style={{ fg: T.textDim }}>Nothing scheduled.</span>
93
+ </text>
94
+ }
95
+ >
96
+ <scrollbox
97
+ ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
98
+ style={{
99
+ width: "100%",
100
+ flexGrow: 1,
101
+ rootOptions: {},
102
+ contentOptions: {},
103
+ scrollbarOptions: {
104
+ visible: false,
105
+ },
106
+ }}
107
+ >
108
+ <RenderGroups
109
+ groups={groups()}
110
+ isActive={isActive()}
111
+ cursorRow={cursorRow()}
112
+ // Panel inner cell width seen by a TaskRow: panel 38 col
113
+ // (or full width in zoom) − border 2 − panel padding 2 −
114
+ // TaskRow padding 2 = 32 cols normal, ~terminal width − 6
115
+ // when zoomed. Pass that so TaskRow can budget the title
116
+ // dynamically against the row's actual overhead.
117
+ availableWidth={isZoomed() ? 100 : 32}
118
+ isMarkedFn={(r) => props.store.isMarked(r)}
119
+ onClickItem={(flatIndex) => {
120
+ props.store.setActiveZone("virtual");
121
+ props.store.setCursor(0, flatIndex);
122
+ }}
123
+ />
124
+ </scrollbox>
125
+ </Show>
126
+ </box>
127
+ );
128
+ }
129
+
130
+ function RenderGroups(props: {
131
+ groups: VirtualGroup[];
132
+ isActive: boolean;
133
+ cursorRow: number;
134
+ availableWidth: number;
135
+ isMarkedFn: (ref: import("~/store/index").TaskRef) => boolean;
136
+ onClickItem: (flatIndex: number) => void;
137
+ }) {
138
+ return (
139
+ <For each={props.groups}>
140
+ {(group, gi) => {
141
+ const sectionHeader = isFirstOfSection(props.groups, gi())
142
+ ? SECTION_HEADER[group.section]
143
+ : undefined;
144
+ const bucketHeader = BUCKET_HEADER[group.bucket];
145
+
146
+ return (
147
+ <box style={{ flexDirection: "column" }}>
148
+ <Show when={sectionHeader}>
149
+ <box style={{ paddingLeft: 1, paddingRight: 1, marginTop: 1 }}>
150
+ <text>
151
+ <span style={{ fg: sectionHeader!.color, attributes: ATTR.bold }}>
152
+ {sectionHeader!.label}
153
+ </span>
154
+ </text>
155
+ </box>
156
+ </Show>
157
+ <Show when={bucketHeader}>
158
+ <box style={{ paddingLeft: 1, paddingRight: 1 }}>
159
+ <text>
160
+ <span style={{ fg: bucketHeader!.color }}>{" "}{bucketHeader!.label}</span>
161
+ </text>
162
+ </box>
163
+ </Show>
164
+ <Show
165
+ when={group.subgroups && group.subgroups.length > 0}
166
+ fallback={
167
+ <For each={group.items}>
168
+ {(item) => (
169
+ <box id={vpRowId(item.flatIndex)}>
170
+ <TaskRow
171
+ task={item.task}
172
+ cursor={props.isActive && item.flatIndex === props.cursorRow}
173
+ marked={props.isMarkedFn(item.ref)}
174
+ availableWidth={props.availableWidth}
175
+ // Tint the title with the source board's accent so a
176
+ // cross-cutting Today/Tomorrow item is recognizable by
177
+ // its board at a glance (done-green still wins).
178
+ tintColor={boardColor(item.boardIndex)}
179
+ // Today / Tomorrow sections already say so in their
180
+ // header — drop the redundant per-row "today"/"tmrw"
181
+ // date label (the ⌚ time block stays). Overdue rows
182
+ // keep their MM/DD so you can see HOW overdue.
183
+ hideDateSuffix={
184
+ group.section === "today" || group.section === "tomorrow"
185
+ }
186
+ onClick={() => props.onClickItem(item.flatIndex)}
187
+ />
188
+ </box>
189
+ )}
190
+ </For>
191
+ }
192
+ >
193
+ <For each={group.subgroups!}>
194
+ {(sub) => (
195
+ <box style={{ flexDirection: "column" }}>
196
+ <box style={{ paddingLeft: 1, paddingRight: 1 }}>
197
+ <text wrapMode="none" truncate>
198
+ <span style={{ fg: T.textDim }}>
199
+ {" — "}{sub.boardName}{" · "}{sub.columnName}{" —"}
200
+ </span>
201
+ </text>
202
+ </box>
203
+ <For each={sub.items}>
204
+ {(item) => (
205
+ <box id={vpRowId(item.flatIndex)}>
206
+ <TaskRow
207
+ task={item.task}
208
+ cursor={props.isActive && item.flatIndex === props.cursorRow}
209
+ marked={props.isMarkedFn(item.ref)}
210
+ availableWidth={props.availableWidth}
211
+ tintColor={boardColor(item.boardIndex)}
212
+ hideDateSuffix={
213
+ group.section === "today" || group.section === "tomorrow"
214
+ }
215
+ onClick={() => props.onClickItem(item.flatIndex)}
216
+ />
217
+ </box>
218
+ )}
219
+ </For>
220
+ </box>
221
+ )}
222
+ </For>
223
+ </Show>
224
+ </box>
225
+ );
226
+ }}
227
+ </For>
228
+ );
229
+ }
230
+
231
+ function isFirstOfSection(
232
+ groups: VirtualGroup[],
233
+ idx: number,
234
+ ): boolean {
235
+ if (idx === 0) return true;
236
+ return groups[idx - 1]!.section !== groups[idx]!.section;
237
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { computeColumnScrollLeft } from "~/ui/board-scroll";
4
+
5
+ const COL = 42;
6
+ const GAP = 1;
7
+ const STRIDE = COL + GAP;
8
+
9
+ // Helper for the common uniform-width case: column at a given index.
10
+ function scroll(index: number, viewportWidth: number, currentScroll: number) {
11
+ return computeColumnScrollLeft({
12
+ colStart: index * STRIDE,
13
+ colWidth: COL,
14
+ viewportWidth,
15
+ currentScroll,
16
+ });
17
+ }
18
+
19
+ describe("computeColumnScrollLeft", () => {
20
+ test("column already fully visible → scroll unchanged", () => {
21
+ expect(scroll(0, 100, 0)).toBe(0);
22
+ expect(scroll(1, 100, 0)).toBe(0);
23
+ });
24
+
25
+ test("column off the right edge → align its right edge to viewport", () => {
26
+ // viewport 60, col 1 spans 43..85 → right-align: 85-60 = 25.
27
+ expect(scroll(1, 60, 0)).toBe(25);
28
+ });
29
+
30
+ test("column off the left edge → align its left edge to viewport", () => {
31
+ expect(scroll(0, 60, 25)).toBe(0);
32
+ });
33
+
34
+ test("far-right hidden column scrolls fully into view", () => {
35
+ // col 5 spans 215..257 → right-align: 257-60 = 197.
36
+ expect(scroll(5, 60, 0)).toBe(197);
37
+ });
38
+
39
+ test("column wider than viewport → align left edge (show the start)", () => {
40
+ // viewport 30 < column 42. col 2 starts at 86 → align left edge at 86.
41
+ expect(scroll(2, 30, 0)).toBe(86);
42
+ });
43
+
44
+ test("negative start or zero viewport → no change", () => {
45
+ expect(
46
+ computeColumnScrollLeft({ colStart: -1, colWidth: COL, viewportWidth: 60, currentScroll: 17 }),
47
+ ).toBe(17);
48
+ expect(scroll(3, 0, 17)).toBe(17);
49
+ });
50
+
51
+ test("never returns a negative scroll offset", () => {
52
+ expect(scroll(0, 200, 0)).toBeGreaterThanOrEqual(0);
53
+ expect(scroll(0, 30, 5)).toBeGreaterThanOrEqual(0);
54
+ });
55
+
56
+ test("variable widths: narrow collapsed column to the left shifts offsets", () => {
57
+ // Columns: [42], [18 collapsed], [42]. Third column starts at
58
+ // 43 + 19 = 62, spans 62..104. viewport 50 → right-align 104-50 = 54.
59
+ expect(
60
+ computeColumnScrollLeft({ colStart: 62, colWidth: 42, viewportWidth: 50, currentScroll: 0 }),
61
+ ).toBe(54);
62
+ });
63
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Deterministic horizontal scroll geometry for the kanban board.
3
+ *
4
+ * The board renders a horizontal row of columns inside a clipping viewport.
5
+ * When the cursor moves to a column that is partly or fully out of view, the
6
+ * row is shifted (via negative margin) so that column becomes visible.
7
+ *
8
+ * Columns are NOT uniform width (a collapsed all-done column is narrower), so
9
+ * the caller passes the active column's actual laid-out start offset and width
10
+ * rather than an index + stride.
11
+ */
12
+
13
+ export interface ColumnScrollInput {
14
+ /** Left offset of the active column within the column row, in cells. */
15
+ colStart: number;
16
+ /** Width of the active column in cells. */
17
+ colWidth: number;
18
+ /** Currently visible width of the viewport in cells. */
19
+ viewportWidth: number;
20
+ /** Current horizontal scroll offset in cells. */
21
+ currentScroll: number;
22
+ }
23
+
24
+ /**
25
+ * Returns the scroll offset that brings the active column fully into view,
26
+ * scrolling the minimum distance needed:
27
+ *
28
+ * - already fully visible → unchanged
29
+ * - off the left edge → align column's left edge to viewport left
30
+ * - off the right edge → align column's right edge to viewport right
31
+ * - wider than the viewport → align left edge (show as much from the
32
+ * start of the column as possible)
33
+ *
34
+ * Pure and side-effect free so it can be unit-tested without a terminal.
35
+ */
36
+ export function computeColumnScrollLeft(input: ColumnScrollInput): number {
37
+ const { colStart, colWidth, viewportWidth, currentScroll } = input;
38
+ if (colStart < 0 || viewportWidth <= 0) return currentScroll;
39
+
40
+ const colEnd = colStart + colWidth;
41
+
42
+ if (colStart < currentScroll) {
43
+ return Math.max(0, colStart);
44
+ }
45
+ if (colEnd > currentScroll + viewportWidth) {
46
+ return Math.max(0, Math.min(colStart, colEnd - viewportWidth));
47
+ }
48
+ return currentScroll;
49
+ }
@@ -0,0 +1,129 @@
1
+ /** Centralized glyph + color tokens for the UI. */
2
+
3
+ import { TextAttributes } from "@opentui/core";
4
+
5
+ export const ATTR = {
6
+ bold: TextAttributes.BOLD,
7
+ dim: TextAttributes.DIM,
8
+ italic: TextAttributes.ITALIC,
9
+ underline: TextAttributes.UNDERLINE,
10
+ } as const;
11
+
12
+ /**
13
+ * Theme tokens.
14
+ *
15
+ * Foreground colors use ANSI names (`"red"`, `"yellow"`, `"brightBlack"`,
16
+ * …) so they pick up the user's configured terminal palette. The terminal
17
+ * theme — Nord, Tokyo Night, Gruvbox, Solarized, whatever — decides what
18
+ * those names look like.
19
+ *
20
+ * Backgrounds are mostly `undefined` (transparent → terminal default).
21
+ * The only opaque backgrounds we paint are the cursor row highlight and
22
+ * the banner row.
23
+ */
24
+ /**
25
+ * Palette philosophy:
26
+ *
27
+ * - Backgrounds stay transparent so the terminal theme bleeds through.
28
+ * - The few accent colors are *muted hex* (mid-saturation, mid-luminance)
29
+ * instead of raw ANSI names like "red"/"yellow" — those tend to render
30
+ * as fully-saturated traffic-light colors in most themes and clash
31
+ * with everything around them.
32
+ * - The Today/Tomorrow virtual panel has its own *warm* identity
33
+ * (peach/orange) so the user instantly knows "this is the time zone";
34
+ * everything else uses a *cool* identity (soft cyan) for active focus.
35
+ * - Cursor row highlight is the only opaque paint outside the modal.
36
+ */
37
+ export const T = {
38
+ // Backgrounds
39
+ bg: undefined as string | undefined,
40
+ panelBg: undefined as string | undefined,
41
+ panelBgActive: undefined as string | undefined,
42
+ cardBg: undefined as string | undefined,
43
+ cardBgDone: undefined as string | undefined,
44
+ cardBgCursor: "#2a2f3c",
45
+ // Subtle fill behind timeline block rows so the bands separate from the
46
+ // dotted/empty gutters around them. Just barely darker-than-cursor; on a
47
+ // typical dark terminal it reads as "filled card", not as "highlighted".
48
+ cardBlockBg: "#1c2030",
49
+
50
+ // Foreground neutrals — readable mid-grays so dim chrome doesn't disappear
51
+ text: undefined as string | undefined, // terminal default fg
52
+ textDim: "#8a90a8", // bumped up from #6b7089 for legibility
53
+ textDone: "#6b7089", // formerly textDim — done tasks are dim but still readable
54
+ done: "#6aaf57", // muted-but-clear green for completed task titles + ✓
55
+
56
+ // Cool accents — used for active board columns and generic focus
57
+ accent: "#7eb6d6", // clearer blue-cyan, hue ~200°
58
+ border: "#5c627a", // mid-gray, visible against terminal default bg
59
+ borderActive: "#7eb6d6", // same as accent
60
+
61
+ // Warm accents — Today/Tomorrow identity (hue ~30°)
62
+ warm: "#e8a05c", // clear warm orange, distinct from red
63
+ warmActive: "#f2b272", // brighter peach when panel is focused
64
+ warmDim: "#a07a52", // dim version for tomorrow header
65
+
66
+ // Priority emoji colors
67
+ highest: "#e26a6a", // clearly red (hue 0°)
68
+ high: "#e8a05c", // warm orange (same as today)
69
+ medium: "#d8c074", // gold (hue 50°)
70
+ low: "#a4c98a", // sage (hue 95°)
71
+
72
+ // Status-based row colors — kept clearly distinct in hue + brightness
73
+ overdue: "#e26a6a", // hue 0°, sat 65%, light 65% — clearly red
74
+ today: "#e8a05c", // hue 30°, sat 75%, light 64% — clearly orange
75
+ scheduled: "#c89a6a", // dimmer warm for non-today future
76
+ future: "#8a90a8",
77
+
78
+ // Metadata
79
+ assignee: "#a4c98a",
80
+ tag: "#7eb6d6",
81
+ time: "#b3a3d8",
82
+
83
+ // Banner colors
84
+ bannerInfo: "#7eb6d6",
85
+ bannerWarn: "#e8a05c",
86
+ bannerError: "#e26a6a",
87
+ } as const;
88
+
89
+ export const PRIORITY_GLYPH: Record<string, string> = {
90
+ highest: "🔺",
91
+ high: "⏫",
92
+ medium: "🔼",
93
+ low: "🔽",
94
+ lowest: "⏬",
95
+ none: "",
96
+ };
97
+
98
+ export const PRIORITY_COLOR: Record<string, string | undefined> = {
99
+ highest: T.highest,
100
+ high: T.high,
101
+ medium: T.medium,
102
+ low: T.low,
103
+ lowest: T.low,
104
+ none: T.text,
105
+ };
106
+
107
+ export function fmtMin(m: number): string {
108
+ const h = Math.floor(m / 60).toString().padStart(2, "0");
109
+ const mm = (m % 60).toString().padStart(2, "0");
110
+ return `${h}:${mm}`;
111
+ }
112
+
113
+ /**
114
+ * Per-board accent palette. Used by the virtual panel to color-code the
115
+ * source-board tag on priority/agenda items, so the user can recognize
116
+ * which board a cross-cutting item came from at a glance.
117
+ *
118
+ * Cycles by board index when there are more boards than colors.
119
+ */
120
+ const BOARD_PALETTE: string[] = [
121
+ "#e8a05c", // warm orange (board 0 — typically the work board)
122
+ "#7eb6d6", // cyan-blue (board 1)
123
+ "#a4c98a", // sage green (board 2)
124
+ "#b3a3d8", // soft violet (board 3)
125
+ ];
126
+
127
+ export function boardColor(idx: number): string {
128
+ return BOARD_PALETTE[((idx % BOARD_PALETTE.length) + BOARD_PALETTE.length) % BOARD_PALETTE.length]!;
129
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Fullscreen list of every local Claude Code session.
3
+ * `tuiboard --view=agents`. Shows ALL sessions (including archived),
4
+ * scrollable, cursor-navigable. The scrollbox follows the cursor via
5
+ * scrollChildIntoView (same trick used in BoardView for active columns).
6
+ */
7
+
8
+ import { For, Show, createEffect, createMemo } from "solid-js";
9
+
10
+ import { AgentRow } from "~/ui/AgentRow";
11
+ import { T } from "~/ui/glyphs";
12
+ import type { TuiStore } from "~/store/index";
13
+
14
+ interface ScrollBoxLike {
15
+ scrollChildIntoView(id: string): void;
16
+ }
17
+
18
+ function rowId(sessionId: string): string {
19
+ // Sanitize sessionId for use as an OpenTUI box id. UUIDs are already safe
20
+ // (alphanumeric + dashes), but defensive belt-and-braces doesn't hurt.
21
+ return `tuiboard-agent-${sessionId.replace(/[^a-zA-Z0-9]/g, "_")}`;
22
+ }
23
+
24
+ export function AgentsOnly(props: { store: TuiStore }) {
25
+ const isActive = () => props.store.state.ui.activeZone === "agents";
26
+ const agentRow = () => props.store.state.ui.row;
27
+ const sessions = createMemo(() => props.store.agents.sessions());
28
+ let scrollBoxRef: ScrollBoxLike | undefined;
29
+
30
+ // Auto-scroll the list so the active row is visible. setTimeout(0) waits
31
+ // for OpenTUI to finish layout before requesting scroll.
32
+ createEffect(() => {
33
+ const row = agentRow();
34
+ if (!isActive() || !scrollBoxRef) return;
35
+ const target = sessions()[row];
36
+ if (!target) return;
37
+ setTimeout(() => {
38
+ try {
39
+ scrollBoxRef?.scrollChildIntoView(rowId(target.sessionId));
40
+ } catch {
41
+ // Child not mounted yet — harmless.
42
+ }
43
+ }, 0);
44
+ });
45
+
46
+ return (
47
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
48
+ <box
49
+ style={{
50
+ flexDirection: "column",
51
+ flexGrow: 1,
52
+ border: true,
53
+ borderStyle: "rounded",
54
+ borderColor: isActive() ? T.borderActive : T.border,
55
+ paddingLeft: 1,
56
+ paddingRight: 1,
57
+ }}
58
+ title={`┤ Agents · ${sessions().length} sessions ├`}
59
+ titleAlignment="left"
60
+ >
61
+ <Show
62
+ when={sessions().length > 0}
63
+ fallback={
64
+ <text>
65
+ <span style={{ fg: T.textDim }}>
66
+ No sessions found in ~/.claude/projects.
67
+ </span>
68
+ </text>
69
+ }
70
+ >
71
+ <scrollbox
72
+ ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
73
+ style={{
74
+ width: "100%",
75
+ flexGrow: 1,
76
+ scrollX: false,
77
+ scrollY: true,
78
+ rootOptions: {},
79
+ contentOptions: {},
80
+ scrollbarOptions: { visible: false },
81
+ }}
82
+ >
83
+ <For each={sessions()}>
84
+ {(session, i) => (
85
+ <box id={rowId(session.sessionId)}>
86
+ <AgentRow
87
+ session={session}
88
+ cursor={isActive() && i() === agentRow()}
89
+ nameMaxChars={120}
90
+ onClick={() => {
91
+ props.store.setActiveZone("agents");
92
+ props.store.setCursor(0, i());
93
+ }}
94
+ />
95
+ </box>
96
+ )}
97
+ </For>
98
+ </scrollbox>
99
+ </Show>
100
+ </box>
101
+ </box>
102
+ );
103
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Standalone fullscreen view for the kanban board (with virtual panel).
3
+ * Mounted when the user launches `tuiboard --view=board`, AND used inside
4
+ * the dashboard via composition.
5
+ *
6
+ * The cursor and modals are governed by the same store as the dashboard;
7
+ * only the layout differs (no Timeline / Agents zones).
8
+ *
9
+ * The modal side-panel is rendered at App level, so this view just lays
10
+ * out its zones in a single row and lets the parent slot the modal in.
11
+ */
12
+
13
+ import { Show, createMemo } from "solid-js";
14
+
15
+ import { BoardView } from "~/ui/BoardView";
16
+ import { VirtualPanel } from "~/ui/VirtualPanel";
17
+ import type { TuiStore } from "~/store/index";
18
+
19
+ export function BoardOnly(props: { store: TuiStore }) {
20
+ const ui = () => props.store.state.ui;
21
+ const activeBoard = createMemo(
22
+ () => props.store.state.boards[ui().activeBoardIndex]?.board,
23
+ );
24
+
25
+ return (
26
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
27
+ <Show when={!ui().zoomed || ui().activeZone === "virtual"}>
28
+ <VirtualPanel store={props.store} />
29
+ </Show>
30
+ <Show when={(!ui().zoomed || ui().activeZone !== "virtual") && activeBoard()}>
31
+ <BoardView store={props.store} board={activeBoard()!} />
32
+ </Show>
33
+ </box>
34
+ );
35
+ }