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,279 @@
1
+ /**
2
+ * Timeline view — derives a per-day vertical schedule from kanban tasks.
3
+ *
4
+ * The store is "pure": given the current boards + a date string, it returns
5
+ * the list of `TimelineEntry` (one per scheduled+time-blocked task) and a
6
+ * pre-computed `RowMap` ready to be rendered as a 64-row vertical column.
7
+ *
8
+ * Read-only by design — edits (resize, move, drag-to-schedule) go through
9
+ * the kanban store's existing setTimeBlock / setScheduled actions.
10
+ */
11
+
12
+ import { isTask } from "~/parser/markdown";
13
+ import type { Board, Task } from "~/types";
14
+ import type { TaskRef } from "~/store/index";
15
+
16
+ /** First hour rendered in the timeline column (inclusive). */
17
+ export const DAY_START_HOUR = 7;
18
+ /** Last hour rendered in the timeline column (exclusive). */
19
+ export const DAY_END_HOUR = 23;
20
+ /** Vertical resolution: each row = N minutes. */
21
+ export const MINS_PER_ROW = 15;
22
+ /** Total renderable rows. (23-7)*60/15 = 64. */
23
+ export const TOTAL_ROWS =
24
+ ((DAY_END_HOUR - DAY_START_HOUR) * 60) / MINS_PER_ROW;
25
+ /** Minimum block height in rows — single-row blocks are unreadable. */
26
+ export const MIN_BLOCK_ROWS = 2;
27
+
28
+ export interface TimelineEntry {
29
+ ref: TaskRef;
30
+ task: Task;
31
+ boardName: string;
32
+ boardIndex: number;
33
+ columnName: string;
34
+ startMin: number;
35
+ endMin: number;
36
+ /** Vertical position in the row grid (clipped to [0, TOTAL_ROWS)). */
37
+ startRow: number;
38
+ /** Exclusive end row. */
39
+ endRow: number;
40
+ }
41
+
42
+ export type RowKind = "empty" | "hour" | "head" | "body" | "fill" | "now";
43
+
44
+ export interface RowMapEntry {
45
+ kind: RowKind;
46
+ /** Hour 0-23 for "hour" kind. */
47
+ hour?: number;
48
+ /** Source entry for head/body/fill kinds. */
49
+ entry?: TimelineEntry;
50
+ /** Now-marker minute (only for "now"). */
51
+ nowMin?: number;
52
+ }
53
+
54
+ /**
55
+ * A row of the timeline grid, paired by lane. The left lane carries the
56
+ * primary content (hour labels, single-lane blocks, the now marker); the
57
+ * right lane is set only when two blocks overlap, in which case the
58
+ * renderer splits the row horizontally into two side-by-side cells.
59
+ */
60
+ export interface RowMapPair {
61
+ left: RowMapEntry;
62
+ right: RowMapEntry;
63
+ }
64
+
65
+ export interface BuildRowMapResult {
66
+ rows: RowMapPair[];
67
+ /** Number of entries that couldn't be placed (3rd+ block in an overlap). */
68
+ overflow: number;
69
+ }
70
+
71
+ /**
72
+ * Build the flat list of time-blocked tasks for the given ISO date.
73
+ * Tasks must be:
74
+ * - non-done
75
+ * - have task.timeBlock
76
+ * - have task.scheduled === date (we ignore `due` for now; calendar-style
77
+ * time-blocking is always scheduled, not due)
78
+ *
79
+ * Sorted ascending by startMin so head/body/fill rendering can claim rows
80
+ * in chronological order.
81
+ */
82
+ export function buildTimelineEntries(
83
+ boards: Board[],
84
+ date: string,
85
+ ): TimelineEntry[] {
86
+ const out: TimelineEntry[] = [];
87
+ for (let bi = 0; bi < boards.length; bi++) {
88
+ const board = boards[bi]!;
89
+ for (let ci = 0; ci < board.columns.length; ci++) {
90
+ const col = board.columns[ci]!;
91
+ let taskIndex = 0;
92
+ for (const child of col.children) {
93
+ if (!isTask(child)) continue;
94
+ const idx = taskIndex++;
95
+ const t = child;
96
+ if (t.done) continue;
97
+ if (!t.timeBlock) continue;
98
+ if (t.scheduled !== date) continue;
99
+
100
+ const { startMin, endMin } = t.timeBlock;
101
+ const windowStart = DAY_START_HOUR * 60;
102
+ const windowEnd = DAY_END_HOUR * 60;
103
+ // Skip blocks that fall entirely outside the rendered window.
104
+ if (endMin <= windowStart || startMin >= windowEnd) continue;
105
+
106
+ const startRow = Math.floor((startMin - windowStart) / MINS_PER_ROW);
107
+ const naturalHeight = Math.max(
108
+ MIN_BLOCK_ROWS,
109
+ Math.floor((endMin - startMin) / MINS_PER_ROW),
110
+ );
111
+ const endRow = startRow + naturalHeight;
112
+
113
+ out.push({
114
+ ref: {
115
+ boardPath: board.filepath,
116
+ columnIndex: ci,
117
+ taskIndex: idx,
118
+ },
119
+ task: t,
120
+ boardName: board.name,
121
+ boardIndex: bi,
122
+ columnName: col.name,
123
+ startMin,
124
+ endMin,
125
+ startRow: Math.max(0, startRow),
126
+ endRow: Math.min(TOTAL_ROWS, endRow),
127
+ });
128
+ }
129
+ }
130
+ }
131
+ out.sort((a, b) => a.startMin - b.startMin);
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Place entries onto the TOTAL_ROWS grid, using up to 2 lanes to render
137
+ * overlapping blocks side-by-side. A third+ overlapping block on the same
138
+ * row is dropped and counted as `overflow` so the renderer can show a
139
+ * banner.
140
+ *
141
+ * Placement strategy (mirrors timeline.py):
142
+ * - Entries are processed in start-time order (already pre-sorted).
143
+ * - Each entry tries lane 0 first; if that lane is still occupied by an
144
+ * earlier block, it tries lane 1; otherwise it counts as overflow.
145
+ * - The `now` marker always lands on lane 0 and clears lane 1 for that
146
+ * row — it's the single most important visual cue and shouldn't be
147
+ * half-hidden behind a block band.
148
+ */
149
+ export function buildRowMap(
150
+ entries: TimelineEntry[],
151
+ nowMin: number,
152
+ ): BuildRowMapResult {
153
+ const left: RowMapEntry[] = Array.from({ length: TOTAL_ROWS }, () => ({
154
+ kind: "empty",
155
+ }));
156
+ const right: RowMapEntry[] = Array.from({ length: TOTAL_ROWS }, () => ({
157
+ kind: "empty",
158
+ }));
159
+
160
+ // Hour labels live on the left lane only.
161
+ for (let r = 0; r < TOTAL_ROWS; r++) {
162
+ if ((r * MINS_PER_ROW) % 60 === 0) {
163
+ left[r] = {
164
+ kind: "hour",
165
+ hour: DAY_START_HOUR + Math.floor((r * MINS_PER_ROW) / 60),
166
+ };
167
+ }
168
+ }
169
+
170
+ // Per-lane "next free row" tracker. -1 means the lane has never held a
171
+ // block yet, so any startRow is admissible.
172
+ const laneEndRow: [number, number] = [-1, -1];
173
+ let overflow = 0;
174
+
175
+ for (const entry of entries) {
176
+ const start = Math.max(0, entry.startRow);
177
+ const end = Math.min(TOTAL_ROWS, entry.endRow);
178
+ if (end <= start) continue;
179
+
180
+ let lane: 0 | 1;
181
+ if (start >= laneEndRow[0]) lane = 0;
182
+ else if (start >= laneEndRow[1]) lane = 1;
183
+ else {
184
+ overflow++;
185
+ continue;
186
+ }
187
+
188
+ const target = lane === 0 ? left : right;
189
+ laneEndRow[lane] = end;
190
+ for (let r = start; r < end; r++) {
191
+ if (r === start) target[r] = { kind: "head", entry };
192
+ else if (r === start + 1) target[r] = { kind: "body", entry };
193
+ else target[r] = { kind: "fill", entry };
194
+ }
195
+ }
196
+
197
+ // Now marker (only when in the visible window). Force both lanes so the
198
+ // renderer can treat it as a full-width row regardless of overlap state.
199
+ const windowStart = DAY_START_HOUR * 60;
200
+ const windowEnd = DAY_END_HOUR * 60;
201
+ if (nowMin >= windowStart && nowMin < windowEnd) {
202
+ const nowRow = Math.floor((nowMin - windowStart) / MINS_PER_ROW);
203
+ if (nowRow >= 0 && nowRow < TOTAL_ROWS) {
204
+ left[nowRow] = { kind: "now", nowMin };
205
+ right[nowRow] = { kind: "empty" };
206
+ }
207
+ }
208
+
209
+ const rows: RowMapPair[] = left.map((l, i) => ({ left: l, right: right[i]! }));
210
+ return { rows, overflow };
211
+ }
212
+
213
+ /**
214
+ * Count time-block overlaps in the entry list. Used to surface a banner so
215
+ * the user knows their schedule has conflicts (the MVP last-writer-wins
216
+ * render would otherwise just silently hide some blocks).
217
+ */
218
+ export function countOverlaps(entries: TimelineEntry[]): number {
219
+ let count = 0;
220
+ for (let i = 0; i < entries.length; i++) {
221
+ for (let j = i + 1; j < entries.length; j++) {
222
+ const a = entries[i]!;
223
+ const b = entries[j]!;
224
+ if (a.startMin < b.endMin && b.startMin < a.endMin) count++;
225
+ }
226
+ }
227
+ return count;
228
+ }
229
+
230
+ export interface UnscheduledItem {
231
+ ref: TaskRef;
232
+ task: Task;
233
+ boardName: string;
234
+ boardIndex: number;
235
+ columnName: string;
236
+ }
237
+
238
+ /**
239
+ * Tasks scheduled for the given date but without a time block — the ones
240
+ * shown in the sticky "◦ Unscheduled" section above the timeline grid.
241
+ * Sorted by board declaration order, then by encounter in the column
242
+ * (preserves the user's manual ordering inside each kanban column).
243
+ */
244
+ export function buildUnscheduledToday(
245
+ boards: Board[],
246
+ date: string,
247
+ ): UnscheduledItem[] {
248
+ const out: UnscheduledItem[] = [];
249
+ for (let bi = 0; bi < boards.length; bi++) {
250
+ const board = boards[bi]!;
251
+ for (let ci = 0; ci < board.columns.length; ci++) {
252
+ const col = board.columns[ci]!;
253
+ let taskIndex = 0;
254
+ for (const child of col.children) {
255
+ if (!isTask(child)) continue;
256
+ const idx = taskIndex++;
257
+ const t = child;
258
+ if (t.done) continue;
259
+ if (t.timeBlock) continue;
260
+ if (t.scheduled !== date) continue;
261
+ out.push({
262
+ ref: { boardPath: board.filepath, columnIndex: ci, taskIndex: idx },
263
+ task: t,
264
+ boardName: board.name,
265
+ boardIndex: bi,
266
+ columnName: col.name,
267
+ });
268
+ }
269
+ }
270
+ }
271
+ return out;
272
+ }
273
+
274
+ /** Format minutes since midnight as "HH:MM". */
275
+ export function formatHm(mins: number): string {
276
+ const h = Math.floor(mins / 60) % 24;
277
+ const m = mins % 60;
278
+ return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
279
+ }
Binary file
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Core data model for tuiboard.
3
+ *
4
+ * Design principle: parsing is *lossy by selection, not by destruction*.
5
+ * We extract the structured fields we know about (dates, assignee, time block,
6
+ * priority, tags, wikilinks), but we always keep `rawBody` and `rawLine` so
7
+ * serialization can round-trip a board file without losing unknown emoji,
8
+ * Obsidian-specific syntax, or decorative content.
9
+ */
10
+
11
+ export type ISODate = string; // YYYY-MM-DD
12
+
13
+ export interface TimeBlock {
14
+ /** Minutes since midnight, inclusive. */
15
+ startMin: number;
16
+ /** Minutes since midnight, exclusive. */
17
+ endMin: number;
18
+ }
19
+
20
+ /** Where the time block was found in the source — drives writer behavior. */
21
+ export type TimeBlockSource = "legacy-prefix" | "watch-emoji";
22
+
23
+ export interface Task {
24
+ /** Stable identity within a board: `${columnIndex}:${indexInColumn}`. */
25
+ id: string;
26
+ done: boolean;
27
+ /** Raw markdown body after the `- [ ] ` / `- [x] ` prefix, verbatim. */
28
+ rawBody: string;
29
+ /** Whole raw source line, used for verbatim round-trip until edited. */
30
+ rawLine: string;
31
+ /** True when the task has been mutated since parsing — serializer rebuilds from structured fields. */
32
+ dirty: boolean;
33
+ /** Display-friendly title with metadata stripped — derived, do not store source-of-truth here. */
34
+ displayTitle: string;
35
+
36
+ // --- Parsed metadata ---
37
+ assignee?: string;
38
+ tags: string[];
39
+ /** Wikilinks: alias if present, otherwise target. */
40
+ wikilinks: string[];
41
+ scheduled?: ISODate;
42
+ due?: ISODate;
43
+ start?: ISODate;
44
+ doneDate?: ISODate;
45
+ priority: PriorityLevel;
46
+ timeBlock?: TimeBlock;
47
+ timeBlockSource?: TimeBlockSource;
48
+ }
49
+
50
+ /** Tasks-plugin priority emojis, in order. `none` = unset. */
51
+ export type PriorityLevel =
52
+ | "highest" // 🔺
53
+ | "high" // ⏫
54
+ | "medium" // 🔼
55
+ | "low" // 🔽
56
+ | "lowest" // ⏬
57
+ | "none";
58
+
59
+ /** A section separator inside a column (`***` under Kanban plugin convention). */
60
+ export interface SectionBreak {
61
+ kind: "section-break";
62
+ rawLine: string;
63
+ }
64
+
65
+ /** A blank line preserved for round-trip fidelity. */
66
+ export interface BlankLine {
67
+ kind: "blank";
68
+ rawLine: string;
69
+ }
70
+
71
+ /** Any other line we didn't recognize (e.g. indented continuation, comments). */
72
+ export interface RawOther {
73
+ kind: "raw";
74
+ rawLine: string;
75
+ }
76
+
77
+ export type ColumnChild = Task | SectionBreak | BlankLine | RawOther;
78
+
79
+ export interface Column {
80
+ name: string;
81
+ /** Header level (almost always 2, i.e. `##`). */
82
+ headerLevel: number;
83
+ /** Raw heading line, for verbatim round-trip. */
84
+ rawHeading: string;
85
+ children: ColumnChild[];
86
+ }
87
+
88
+ export interface Board {
89
+ /** Absolute filesystem path. */
90
+ filepath: string;
91
+ /** Display name from frontmatter `name:` or from filename. */
92
+ name: string;
93
+ /** Verbatim frontmatter block including `---` fences, or empty. */
94
+ frontmatter: string;
95
+ /** Content between frontmatter and first column heading (verbatim, may be blank lines). */
96
+ preamble: string;
97
+ columns: Column[];
98
+ /** Trailing content after the last column (e.g. `%% kanban:settings %%`). Verbatim. */
99
+ trailer: string;
100
+ /** Detected line ending — `\n` or `\r\n`. Used by serializer. */
101
+ lineEnding: "\n" | "\r\n";
102
+ /** Original full text — kept for diffing on conflict detection later. */
103
+ originalContent: string;
104
+ }
105
+
106
+ export interface ParseDiagnostic {
107
+ /** 1-based line number. */
108
+ line: number;
109
+ level: "info" | "warn" | "error";
110
+ message: string;
111
+ }
112
+
113
+ export interface ParseResult {
114
+ board: Board;
115
+ diagnostics: ParseDiagnostic[];
116
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Single-line render of an AgentSession. Used in both AgentsBar (compact
3
+ * dashboard strip) and AgentsOnly (fullscreen list).
4
+ *
5
+ * Layout: cursor · status-dot · name · git branch · cwd_short · age
6
+ */
7
+
8
+ import { Show, createMemo } from "solid-js";
9
+
10
+ import { T } from "~/ui/glyphs";
11
+ import { formatAge, type AgentSession, type AgentStatus } from "~/store/agents";
12
+
13
+ const STATUS_COLOR: Record<AgentStatus, string> = {
14
+ "live-busy": T.today, // bright accent for actively-running
15
+ "live-idle": T.scheduled, // warm but quieter
16
+ "stale-pid": T.bannerWarn,
17
+ "dormant": T.textDim,
18
+ "archived": T.textDone,
19
+ };
20
+
21
+ const STATUS_GLYPH: Record<AgentStatus, string> = {
22
+ "live-busy": "●",
23
+ "live-idle": "○",
24
+ "stale-pid": "△",
25
+ "dormant": "·",
26
+ "archived": "·",
27
+ };
28
+
29
+ interface AgentRowProps {
30
+ session: AgentSession;
31
+ cursor?: boolean;
32
+ /** Maximum chars for displayName before truncation. Default 40. */
33
+ nameMaxChars?: number;
34
+ onClick?: () => void;
35
+ }
36
+
37
+ export function AgentRow(props: AgentRowProps) {
38
+ const ageStr = createMemo(() =>
39
+ formatAge(props.session.lastActivityMs, Date.now()),
40
+ );
41
+ const nameMax = () => props.nameMaxChars ?? 40;
42
+ const displayName = createMemo(() => {
43
+ const n = props.session.displayName;
44
+ return n.length > nameMax() ? n.slice(0, nameMax() - 1) + "…" : n;
45
+ });
46
+
47
+ return (
48
+ <box
49
+ style={{
50
+ flexDirection: "row",
51
+ paddingLeft: 1,
52
+ paddingRight: 1,
53
+ backgroundColor: props.cursor ? T.cardBgCursor : undefined,
54
+ }}
55
+ onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
56
+ >
57
+ {/* `truncate` is on as a safety net — our own displayName tail
58
+ truncation in AgentRow normally controls the visible string,
59
+ but OpenTUI's clip-at-cell-bound prevents bleed into adjacent
60
+ renderables in edge cases (terminal emoji width quirks etc.). */}
61
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
62
+ <span style={{ fg: props.cursor ? T.accent : T.textDim }}>
63
+ {props.cursor ? "▶ " : " "}
64
+ </span>
65
+ <span style={{ fg: STATUS_COLOR[props.session.status] }}>
66
+ {STATUS_GLYPH[props.session.status]}{" "}
67
+ </span>
68
+ <span style={{ fg: T.text }}>{displayName()}</span>
69
+ <Show when={props.session.gitBranch}>
70
+ <span style={{ fg: T.textDim }}>{" "}{props.session.gitBranch}</span>
71
+ </Show>
72
+ <span style={{ fg: T.textDim }}>{" "}{props.session.cwdShort}</span>
73
+ </text>
74
+ <text style={{ flexShrink: 0 }} wrapMode="none">
75
+ <span style={{ fg: T.textDim }}>{" "}{ageStr()}</span>
76
+ </text>
77
+ </box>
78
+ );
79
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Compact agent status strip for the dashboard. Renders a windowed slice
3
+ * of the non-archived sessions list so the cursor stays inside the view
4
+ * even when the underlying list is far larger than the visible rows.
5
+ *
6
+ * The border color reflects activeZone === "agents" so the cursor
7
+ * ring is visible. Clicking a row sets activeZone + agent cursor.
8
+ */
9
+
10
+ import { For, Show, createMemo } from "solid-js";
11
+
12
+ import { AgentRow } from "~/ui/AgentRow";
13
+ import { T } from "~/ui/glyphs";
14
+ import type { TuiStore } from "~/store/index";
15
+
16
+ interface AgentsBarProps {
17
+ store: TuiStore;
18
+ /** Fixed row height in the dashboard layout. */
19
+ height?: number;
20
+ /** Max sessions to show in the compact strip. Default 5 (fits in height=7). */
21
+ maxVisible?: number;
22
+ }
23
+
24
+ interface WindowedEntry {
25
+ index: number;
26
+ // We don't import AgentSession here; carrying through the For element is fine.
27
+ // Solid will infer the type.
28
+ session: ReturnType<TuiStore["agents"]["sessions"]>[number];
29
+ }
30
+
31
+ export function AgentsBar(props: AgentsBarProps) {
32
+ const isActive = () => props.store.state.ui.activeZone === "agents";
33
+ const agentRow = () => props.store.state.ui.row;
34
+ const maxVisible = () => props.maxVisible ?? 5;
35
+
36
+ /** All visible (non-archived) sessions — full list. */
37
+ const allShown = createMemo(() =>
38
+ props.store.agents.sessions().filter((s) => s.status !== "archived"),
39
+ );
40
+
41
+ /**
42
+ * Windowed slice that follows the cursor. When the cursor falls outside
43
+ * the current window, we slide so it sits in the middle (when possible).
44
+ */
45
+ const windowed = createMemo<WindowedEntry[]>(() => {
46
+ const all = allShown();
47
+ const max = maxVisible();
48
+ if (all.length <= max) {
49
+ return all.map((session, index) => ({ index, session }));
50
+ }
51
+ const cursor = isActive() ? agentRow() : 0;
52
+ const halfWin = Math.floor(max / 2);
53
+ let start = Math.max(0, cursor - halfWin);
54
+ let end = Math.min(all.length, start + max);
55
+ if (end - start < max) start = Math.max(0, end - max);
56
+ return all.slice(start, end).map((session, i) => ({
57
+ index: start + i,
58
+ session,
59
+ }));
60
+ });
61
+
62
+ return (
63
+ <box
64
+ style={{
65
+ flexDirection: "column",
66
+ height: props.height,
67
+ flexGrow: props.height ? 0 : 1,
68
+ marginTop: 1,
69
+ border: true,
70
+ borderStyle: "rounded",
71
+ borderColor: isActive() ? T.borderActive : T.border,
72
+ paddingLeft: 1,
73
+ paddingRight: 1,
74
+ }}
75
+ title={`┤ Agents (live) · ${allShown().length} ├`}
76
+ titleAlignment="left"
77
+ >
78
+ <Show
79
+ when={allShown().length > 0}
80
+ fallback={
81
+ <text>
82
+ <span style={{ fg: T.textDim }}>No active sessions.</span>
83
+ </text>
84
+ }
85
+ >
86
+ <For each={windowed()}>
87
+ {(entry) => (
88
+ <AgentRow
89
+ session={entry.session}
90
+ cursor={isActive() && entry.index === agentRow()}
91
+ nameMaxChars={48}
92
+ onClick={() => {
93
+ props.store.setActiveZone("agents");
94
+ props.store.setCursor(0, entry.index);
95
+ }}
96
+ />
97
+ )}
98
+ </For>
99
+ </Show>
100
+ </box>
101
+ );
102
+ }