tuiboard 0.10.0 → 0.12.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.
@@ -29,7 +29,15 @@ import {
29
29
  createBoardWatcher,
30
30
  type BoardWatcher,
31
31
  } from "~/io/watcher";
32
- import { createAgentsStore, type AgentsStore } from "./agents";
32
+ import {
33
+ HARNESS,
34
+ createAgentsStore,
35
+ filterSessions,
36
+ type AgentSession,
37
+ type AgentsFilter,
38
+ type AgentsStore,
39
+ } from "./agents";
40
+ import { AGENT_ADAPTERS } from "./agent-adapters";
33
41
  import {
34
42
  createCalendarStore,
35
43
  createGoogleEvent,
@@ -243,6 +251,12 @@ export interface UIState {
243
251
  * entries and the calendar overlay. Changed with `[` / `]`; `\` resets to 0.
244
252
  */
245
253
  agendaOffset: number;
254
+ /**
255
+ * Vertical scroll position of the help modal, as a block index (0 = top).
256
+ * `?` opens the help at 0; `j`/`k` step it. The HelpModal component clamps
257
+ * the upper bound (it knows the block count) and scrolls that block into view.
258
+ */
259
+ helpScroll: number;
246
260
  view: ViewMode;
247
261
  /**
248
262
  * Tasks marked for bulk ops (`Space`). Key format:
@@ -253,6 +267,10 @@ export interface UIState {
253
267
  marked: Record<string, true>;
254
268
  /** Active filter. */
255
269
  filter: "all" | "today" | "overdue" | "tomorrow" | "followup";
270
+ /** Agents-zone harness filter (`f` while the Agents zone is active). */
271
+ agentsFilter: AgentsFilter;
272
+ /** Outcome of the last Enter (open session), shown in that session's detail. */
273
+ lastLaunch?: { sessionId: string; ok: boolean; text: string; ts: number };
256
274
  /** Banner messages (errors, conflicts, undo notifications). */
257
275
  banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
258
276
  /** Open modal, if any. Keyboard handler routes input to the modal when set. */
@@ -339,9 +357,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
339
357
  grabbing: false,
340
358
  armMode: false,
341
359
  agendaOffset: 0,
360
+ helpScroll: 0,
342
361
  view: "kanban",
343
362
  marked: {},
344
363
  filter: "all",
364
+ agentsFilter: "all",
345
365
  },
346
366
  undo: [],
347
367
  rev: 0,
@@ -355,12 +375,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
355
375
  initialBoards.map((b) => b.board.filepath),
356
376
  );
357
377
 
358
- // Agents store has its own lifecycle (chokidar watcher on ~/.claude).
359
- // Shared dispose() boundary below so SIGINT cleans both. When the agents
360
- // zone is disabled we skip the watcher entirely (no `~/.claude` reads at all)
361
- // and hand back an inert stub.
378
+ // Agents store has its own lifecycle (chokidar watcher on each agent CLI's
379
+ // session dirs). Shared dispose() boundary below so SIGINT cleans both. When
380
+ // the agents zone is disabled we skip the watcher entirely (no session reads
381
+ // at all) and hand back an inert stub.
362
382
  const agentsStore: AgentsStore = enabledZones.agents
363
- ? createAgentsStore()
383
+ ? createAgentsStore(AGENT_ADAPTERS)
364
384
  : noopAgentsStore();
365
385
  // Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
366
386
  // (no network) when the agenda zone is disabled.
@@ -379,6 +399,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
379
399
  // change — and spuriously flash "Reloaded after external edit". Skip.
380
400
  if (lastWrittenContent.get(filepath) === content) return;
381
401
  const { board } = parseBoard(content, { filepath });
402
+ const boardConfig = config.boards.find((cb) => cb.path === filepath);
403
+ if (boardConfig?.name) board.name = boardConfig.name;
382
404
  const mtimeMs = statMtime(filepath);
383
405
  setState(
384
406
  "boards",
@@ -1221,6 +1243,28 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1221
1243
  setState("ui", "zoomed", (z: boolean) => !z);
1222
1244
  }
1223
1245
 
1246
+ /**
1247
+ * Sessions the Agents zone lists, after the harness filter. The dashboard
1248
+ * strip additionally hides archived ones — they sort last, so row indices
1249
+ * into this list stay valid for both views.
1250
+ */
1251
+ function agentSessions(): AgentSession[] {
1252
+ return filterSessions(agentsStore.sessions(), state.ui.agentsFilter);
1253
+ }
1254
+
1255
+ function setLastLaunch(sessionId: string, ok: boolean, text: string): void {
1256
+ setState("ui", "lastLaunch", { sessionId, ok, text, ts: Date.now() });
1257
+ }
1258
+
1259
+ /** Cycle the Agents-zone filter: all → cc → cx → oc → pi → all. */
1260
+ function cycleAgentsFilter(): AgentsFilter {
1261
+ const cycle: AgentsFilter[] = ["all", ...(Object.keys(HARNESS) as AgentsFilter[])];
1262
+ const next = cycle[(cycle.indexOf(state.ui.agentsFilter) + 1) % cycle.length]!;
1263
+ setState("ui", "agentsFilter", next);
1264
+ setState("ui", "row", 0);
1265
+ return next;
1266
+ }
1267
+
1224
1268
  function setFilter(f: UIState["filter"]): void {
1225
1269
  setState("ui", "filter", f);
1226
1270
  // The cursor's row was an index into the unfiltered list — reset to top
@@ -1299,7 +1343,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1299
1343
 
1300
1344
  /**
1301
1345
  * Manual full refresh (the `r` key). Re-reads every board from disk, rescans
1302
- * Claude Code agents, and force-refetches the Agenda's calendar (bypassing
1346
+ * agent sessions, and force-refetches the Agenda's calendar (bypassing
1303
1347
  * the 30-min cache). Lets the user pull in external changes — a calendar
1304
1348
  * event edited in the browser, a board touched elsewhere — without leaving
1305
1349
  * tuiboard. Boards normally auto-reload via the file watcher; this also
@@ -1494,6 +1538,8 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1494
1538
  }
1495
1539
 
1496
1540
  function openModal(m: ModalKind): void {
1541
+ // Help always opens scrolled to the top.
1542
+ if (m.kind === "help") setState("ui", "helpScroll", 0);
1497
1543
  setState("ui", "modal", m);
1498
1544
  }
1499
1545
 
@@ -1551,6 +1597,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1551
1597
  if (cals.length === 1) void confirmEventPicker();
1552
1598
  }
1553
1599
 
1600
+ /** Scroll the help modal by setting its block index (lower-clamped at 0; the
1601
+ * HelpModal component enforces the upper bound against its block count). */
1602
+ function setHelpScroll(n: number): void {
1603
+ setState("ui", "helpScroll", Math.max(0, n));
1604
+ }
1605
+
1554
1606
  /** Move the step-2 calendar selection (wraps). */
1555
1607
  function setEventSel(n: number): void {
1556
1608
  setState("ui", "eventPicker", produce((p: EventPicker | undefined) => {
@@ -1679,6 +1731,9 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1679
1731
  config,
1680
1732
  activeBoard,
1681
1733
  agents: agentsStore,
1734
+ agentSessions,
1735
+ cycleAgentsFilter,
1736
+ setLastLaunch,
1682
1737
  calendar: calendarStore,
1683
1738
  // queries
1684
1739
  getBoardByPath,
@@ -1740,6 +1795,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1740
1795
  closeModal,
1741
1796
  openEventModal,
1742
1797
  advanceEventToStep2,
1798
+ setHelpScroll,
1743
1799
  setEventSel,
1744
1800
  confirmEventPicker,
1745
1801
  selectCalEvent,
@@ -1,19 +1,35 @@
1
1
  /**
2
- * Single-line render of an AgentSession. Used in both AgentsBar (compact
3
- * dashboard strip) and AgentsOnly (fullscreen list).
2
+ * Render of an AgentSession, in two densities:
4
3
  *
5
- * Layout: cursor · status-dot · name · git branch ……right-pinned: cwd_short · age
4
+ * line (AgentsBar — the 5-row dashboard strip)
5
+ * cursor · status · harness · name · branch · model ……right-pinned: cwd_short · age
6
+ * Fitted to the measured row width (see agent-line.ts): on a narrow row the
7
+ * model goes first, then the branch, then the cwd — the name survives longest.
8
+ *
9
+ * card (AgentsOnly — fullscreen / zoomed Agents view)
10
+ * cursor · status · harness · name ………………………………… age
11
+ * model · branch · cwd (dimmed)
6
12
  */
7
13
 
8
- import { Show, createMemo } from "solid-js";
14
+ import { Show, createMemo, createSignal, onMount } from "solid-js";
15
+ import { homedir } from "node:os";
9
16
 
17
+ import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
10
18
  import { T } from "~/ui/glyphs";
11
- import { formatAge, type AgentSession, type AgentStatus } from "~/store/agents";
19
+ import {
20
+ HARNESS,
21
+ cwdShort,
22
+ formatAge,
23
+ shortModel,
24
+ type AgentProvider,
25
+ type AgentSession,
26
+ type AgentStatus,
27
+ } from "~/store/agents";
12
28
 
13
29
  const STATUS_COLOR: Record<AgentStatus, string> = {
14
30
  "live-busy": T.today, // bright accent for actively-running
15
31
  "live-idle": T.scheduled, // warm but quieter
16
- "stale-pid": T.bannerWarn,
32
+ "stale": T.bannerWarn,
17
33
  "dormant": T.textDim,
18
34
  "archived": T.textDone,
19
35
  };
@@ -21,16 +37,35 @@ const STATUS_COLOR: Record<AgentStatus, string> = {
21
37
  const STATUS_GLYPH: Record<AgentStatus, string> = {
22
38
  "live-busy": "●",
23
39
  "live-idle": "○",
24
- "stale-pid": "△",
40
+ "stale": "△",
25
41
  "dormant": "·",
26
42
  "archived": "·",
27
43
  };
28
44
 
45
+ /** One hue per harness so the badge reads before the letters do. */
46
+ export const HARNESS_COLOR: Record<AgentProvider, string> = {
47
+ "claude-code": T.warm, // orange
48
+ codex: T.accent, // blue-cyan
49
+ opencode: "#d27ee0", // violet-fuchsia
50
+ pi: "#c3d94e", // acid yellow-green
51
+ };
52
+
53
+ /** Card cwd: `~`-relative when under $HOME, shortened when still long. */
54
+ const HOME = homedir();
55
+ const CARD_CWD_MAX = 60;
56
+ function cardCwd(cwd: string): string {
57
+ const underHome = cwd === HOME || cwd.startsWith(HOME + "/") || cwd.startsWith(HOME + "\\");
58
+ const shown = underHome ? "~" + cwd.slice(HOME.length) : cwd;
59
+ return shown.length > CARD_CWD_MAX ? cwdShort(cwd) : shown;
60
+ }
61
+
29
62
  interface AgentRowProps {
30
63
  session: AgentSession;
31
64
  cursor?: boolean;
32
65
  /** Maximum chars for displayName before truncation. Default 40. */
33
66
  nameMaxChars?: number;
67
+ /** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
68
+ variant?: "line" | "card";
34
69
  onClick?: () => void;
35
70
  }
36
71
 
@@ -43,42 +78,113 @@ export function AgentRow(props: AgentRowProps) {
43
78
  const n = props.session.displayName;
44
79
  return n.length > nameMax() ? n.slice(0, nameMax() - 1) + "…" : n;
45
80
  });
81
+ const model = createMemo(() => shortModel(props.session.model));
82
+
83
+ // Measured row width; undefined until the first layout pass.
84
+ const [width, setWidth] = createSignal<number | undefined>();
85
+ let rowRef: { width: number } | undefined;
86
+ const measure = () => {
87
+ if (rowRef && rowRef.width > 0) setWidth(rowRef.width);
88
+ };
89
+ onMount(() => setTimeout(measure, 0));
90
+ const sizeProps = {
91
+ ref: (r: { width: number }) => (rowRef = r),
92
+ onSizeChange: measure,
93
+ };
94
+
95
+ const line = createMemo(() =>
96
+ layoutLine(
97
+ {
98
+ name: displayName(),
99
+ branch: props.session.gitBranch,
100
+ model: model(),
101
+ cwd: props.session.cwdShort,
102
+ age: ageStr().padStart(3),
103
+ },
104
+ width(),
105
+ ),
106
+ );
107
+ const cardName = createMemo(() => layoutCardName(displayName(), width()));
108
+ const details = createMemo(() =>
109
+ layoutCardDetails([model(), props.session.gitBranch, cardCwd(props.session.cwd)], width()),
110
+ );
111
+
112
+ // cursor · status glyph · harness badge — shared by both variants.
113
+ const lead = () => [
114
+ <span style={{ fg: props.cursor ? T.accent : T.textDim }}>
115
+ {props.cursor ? "▶ " : " "}
116
+ </span>,
117
+ <span style={{ fg: STATUS_COLOR[props.session.status] }}>
118
+ {STATUS_GLYPH[props.session.status]}{" "}
119
+ </span>,
120
+ <span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
121
+ {HARNESS[props.session.provider].code}{" "}
122
+ </span>,
123
+ ];
46
124
 
47
125
  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}
126
+ <Show
127
+ when={props.variant === "card"}
128
+ fallback={
129
+ <box
130
+ {...sizeProps}
131
+ style={{
132
+ flexDirection: "row",
133
+ paddingLeft: 1,
134
+ paddingRight: 1,
135
+ backgroundColor: props.cursor ? T.cardBgCursor : undefined,
136
+ }}
137
+ onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
138
+ >
139
+ {/* `truncate` stays on as a safety net only — layoutLine already
140
+ fits the fields to the measured width with a tail cut, while
141
+ OpenTUI's own truncation would elide mid-string. */}
142
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
143
+ {lead()}
144
+ <span style={{ fg: T.text }}>{line().name}</span>
145
+ <Show when={line().branch}>
146
+ <span style={{ fg: T.warmDim }}>{" "}{line().branch}</span>
147
+ </Show>
148
+ <Show when={line().model}>
149
+ <span style={{ fg: T.textDim }}>{" "}{line().model}</span>
150
+ </Show>
151
+ </text>
152
+ {/* cwd + age pinned together on the right. The age is right-aligned in a
153
+ fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
154
+ cwd lands on the same column across rows — a 1- vs 2-digit age no
155
+ longer shoves the directory names out of vertical alignment. */}
156
+ <text style={{ flexShrink: 0 }} wrapMode="none">
157
+ <span style={{ fg: T.textDim }}>{" "}{line().right}</span>
158
+ </text>
159
+ </box>
160
+ }
56
161
  >
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
- </text>
73
- {/* cwd + age pinned together on the right. The age is right-aligned in a
74
- fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
75
- cwd lands on the same column across rows — a 1- vs 2-digit age no
76
- longer shoves the directory names out of vertical alignment. */}
77
- <text style={{ flexShrink: 0 }} wrapMode="none">
78
- <span style={{ fg: T.textDim }}>
79
- {props.session.cwdShort}{" "}{ageStr().padStart(3)}
80
- </span>
81
- </text>
82
- </box>
162
+ <box
163
+ {...sizeProps}
164
+ style={{
165
+ flexDirection: "column",
166
+ paddingLeft: 1,
167
+ paddingRight: 1,
168
+ backgroundColor: props.cursor ? T.cardBgCursor : undefined,
169
+ }}
170
+ onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
171
+ >
172
+ <box style={{ flexDirection: "row" }}>
173
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
174
+ {lead()}
175
+ <span style={{ fg: T.text }}>{cardName()}</span>
176
+ </text>
177
+ <text style={{ flexShrink: 0 }} wrapMode="none">
178
+ <span style={{ fg: T.textDim }}>{" "}{ageStr().padStart(3)}</span>
179
+ </text>
180
+ </box>
181
+ {/* Indent = width of "▶ ● cc " so the details line up under the name. */}
182
+ <box style={{ flexDirection: "row", paddingLeft: 7 }}>
183
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
184
+ <span style={{ fg: T.textDim }}>{details()}</span>
185
+ </text>
186
+ </box>
187
+ </box>
188
+ </Show>
83
189
  );
84
190
  }
@@ -12,6 +12,7 @@ import { For, Show, createEffect, createMemo } from "solid-js";
12
12
 
13
13
  import { AgentRow } from "~/ui/AgentRow";
14
14
  import { T } from "~/ui/glyphs";
15
+ import { HARNESS } from "~/store/agents";
15
16
  import type { TuiStore } from "~/store/index";
16
17
 
17
18
  interface ScrollBoxLike {
@@ -31,10 +32,14 @@ export function AgentsBar(props: AgentsBarProps) {
31
32
  const isActive = () => props.store.state.ui.activeZone === "agents";
32
33
  const agentRow = () => props.store.state.ui.row;
33
34
 
34
- /** All visible (non-archived) sessions. */
35
+ /** All visible (non-archived) sessions, after the harness filter. */
35
36
  const allShown = createMemo(() =>
36
- props.store.agents.sessions().filter((s) => s.status !== "archived"),
37
+ props.store.agentSessions().filter((s) => s.status !== "archived"),
37
38
  );
39
+ const filterTag = () => {
40
+ const f = props.store.state.ui.agentsFilter;
41
+ return f === "all" ? "" : ` · ${HARNESS[f].code}`;
42
+ };
38
43
 
39
44
  let scrollBoxRef: ScrollBoxLike | undefined;
40
45
 
@@ -68,7 +73,7 @@ export function AgentsBar(props: AgentsBarProps) {
68
73
  paddingLeft: 1,
69
74
  paddingRight: 1,
70
75
  }}
71
- title={`┤ Agents (live) · ${allShown().length} ├`}
76
+ title={`┤ Agents (live)${filterTag()} · ${allShown().length} ├`}
72
77
  titleAlignment="left"
73
78
  >
74
79
  <Show
@@ -8,7 +8,7 @@
8
8
  * board width.
9
9
  */
10
10
 
11
- import { For, Show, createEffect, createMemo, createSignal, onMount } from "solid-js";
11
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
12
12
 
13
13
  import { isHiddenColumn } from "~/config/loader";
14
14
  import { isTask } from "~/parser/markdown";
@@ -122,6 +122,11 @@ export function BoardView(props: BoardViewProps) {
122
122
  const colIdx = ui().col;
123
123
  if (singlePane() || ui().activeZone === "planner") {
124
124
  setScrollX(0);
125
+ // Refresh viewportW too - the zoomed title budget depends on it.
126
+ setTimeout(() => {
127
+ const vw = viewportRef?.width ?? 0;
128
+ if (vw > 0) setViewportW(vw);
129
+ }, 0);
125
130
  return;
126
131
  }
127
132
  const cols = props.board.columns;
@@ -178,6 +183,18 @@ export function BoardView(props: BoardViewProps) {
178
183
  }, 0);
179
184
  });
180
185
 
186
+ // Re-measure on resize while zoomed (the scroll effect above only
187
+ // reruns on col/zoomed/activeZone changes, not plain resizes).
188
+ const onResize = () => {
189
+ // 50ms, not 0ms: give OpenTUI's layout pass time to catch up.
190
+ setTimeout(() => {
191
+ const vw = viewportRef?.width ?? 0;
192
+ if (vw > 0) setViewportW(vw);
193
+ }, 50);
194
+ };
195
+ process.stdout.on("resize", onResize);
196
+ onCleanup(() => process.stdout.off("resize", onResize));
197
+
181
198
  return (
182
199
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
183
200
  {/* Clipping viewport: fills the board zone, hides overflow. */}
@@ -216,6 +233,7 @@ export function BoardView(props: BoardViewProps) {
216
233
  zoomed={singlePane() && isActive()}
217
234
  tasksVisible={columnTasksVisible(i())}
218
235
  boxId={columnId(props.board.filepath, originalIndex)}
236
+ viewportWidth={viewportW()}
219
237
  />
220
238
  );
221
239
  }}
@@ -245,6 +263,9 @@ interface ColumnViewProps {
245
263
  tasksVisible?: boolean;
246
264
  /** Stable DOM-equivalent id used by `scrollChildIntoView`. */
247
265
  boxId: string;
266
+ /** Real measured board viewport width in cells, used to size the
267
+ * title budget for a zoomed column instead of a hardcoded guess. */
268
+ viewportWidth?: number;
248
269
  }
249
270
 
250
271
  /** Stable id for a task row inside a column, used by scrollChildIntoView. */
@@ -395,9 +416,19 @@ function ColumnView(props: ColumnViewProps) {
395
416
  }
396
417
  // Column inner cell width for a TaskRow: COL_WIDTH 42 −
397
418
  // border 2 − col padding 2 − TaskRow padding 2 = 36 cols
398
- // (when not zoomed). Zoomed → column grows to fill, so
399
- // ~terminal width − some chrome.
400
- availableWidth={props.zoomed ? 100 : 36}
419
+ // (when not zoomed). Zoomed: use the real measured
420
+ // viewport width, same 6-cell overhead, plus a 2-cell
421
+ // safety margin for width-measurement drift (fixes a
422
+ // double-truncation glitch, e.g. "walk t...gh backgro...").
423
+ availableWidth={
424
+ props.zoomed
425
+ ? Math.max(6, (props.viewportWidth ?? 42) - 8)
426
+ : 36
427
+ }
428
+ // Default hardCap (60 chars) was clipping titles well
429
+ // short of the zoomed column's real width. Let
430
+ // availableWidth alone govern the budget when zoomed.
431
+ titleMaxChars={props.zoomed ? 1000 : undefined}
401
432
  onClick={() => {
402
433
  props.store.setActiveZone("board");
403
434
  props.store.setCursor(props.columnIndex, ri());