tuiboard 0.11.0 → 0.13.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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * herdr (terminal workspace manager for coding agents) link.
3
+ *
4
+ * `herdr api snapshot` lists every pane with the agent running in it, that
5
+ * agent's state and — when herdr's integration for that agent is installed —
6
+ * the agent session it's on (`{kind: "id"}` for Claude/OpenCode, `{kind:
7
+ * "path"}` for Pi). tuiboard polls it and links panes to its own sessions, so
8
+ * a session open in herdr shows herdr's live state (including "idle" and
9
+ * "waiting for you", which the on-disk formats can't tell) and where it lives.
10
+ *
11
+ * Optional by design: no `herdr` on PATH, no server, or an unexpected answer
12
+ * all just mean "no links".
13
+ */
14
+
15
+ import type { AgentProvider, AgentSession, AgentStatus } from "~/store/agents";
16
+
17
+ export type HerdrAgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
18
+
19
+ export interface HerdrPane {
20
+ paneId: string;
21
+ tabId: string;
22
+ workspaceId: string;
23
+ agent?: string;
24
+ status: HerdrAgentStatus;
25
+ cwd: string;
26
+ session?: { kind: "id" | "path"; value: string };
27
+ }
28
+
29
+ export interface HerdrSnapshot {
30
+ panes: HerdrPane[];
31
+ focusedWorkspaceId?: string;
32
+ tabs: Map<string, { label: string; number: number }>;
33
+ workspaces: Map<string, { label: string; number: number }>;
34
+ }
35
+
36
+ /** The `herdr` binary to drive: the one herdr itself advertises, else PATH. */
37
+ export function herdrBin(env: Record<string, string | undefined> = process.env): string | undefined {
38
+ return env.HERDR_BIN_PATH || Bun.which("herdr") || undefined;
39
+ }
40
+
41
+ /** Where a session is open in herdr. */
42
+ export interface HerdrLink {
43
+ paneId: string;
44
+ tabId: string;
45
+ workspaceId: string;
46
+ workspaceLabel: string;
47
+ tabLabel: string;
48
+ tabNumber: number;
49
+ status: HerdrAgentStatus;
50
+ /** How the pane was matched; `cwd` = no session identity from herdr. */
51
+ matchedBy: "id" | "path" | "cwd";
52
+ /** herdr's name for the agent (for `herdr integration install <name>`). */
53
+ agent: string;
54
+ }
55
+
56
+ /** `herdr blits · tab 3` */
57
+ export function herdrPlace(l: HerdrLink): string {
58
+ return `herdr ${l.workspaceLabel || l.workspaceId} · tab ${l.tabNumber || l.tabId}`;
59
+ }
60
+
61
+ /** herdr agent name ↔ tuiboard provider. */
62
+ export const HERDR_AGENT: Record<AgentProvider, string> = {
63
+ "claude-code": "claude",
64
+ codex: "codex",
65
+ opencode: "opencode",
66
+ pi: "pi",
67
+ };
68
+ const PROVIDER_OF: Record<string, AgentProvider> = Object.fromEntries(
69
+ Object.entries(HERDR_AGENT).map(([p, a]) => [a, p as AgentProvider]),
70
+ );
71
+
72
+ const STATUSES = new Set<HerdrAgentStatus>(["idle", "working", "blocked", "done", "unknown"]);
73
+
74
+ /** Parse `herdr api snapshot` output. Returns undefined if it isn't one. */
75
+ export function parseHerdrSnapshot(raw: string): HerdrSnapshot | undefined {
76
+ let data: any;
77
+ try {
78
+ data = JSON.parse(raw);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ const snap = data?.result?.snapshot ?? data?.snapshot;
83
+ if (!snap || !Array.isArray(snap.panes)) return undefined;
84
+ const panes: HerdrPane[] = [];
85
+ for (const p of snap.panes) {
86
+ if (typeof p?.pane_id !== "string") continue;
87
+ const s = p.agent_session;
88
+ panes.push({
89
+ paneId: p.pane_id,
90
+ tabId: String(p.tab_id ?? ""),
91
+ workspaceId: String(p.workspace_id ?? ""),
92
+ agent: typeof p.agent === "string" ? p.agent : undefined,
93
+ status: STATUSES.has(p.agent_status) ? p.agent_status : "unknown",
94
+ cwd: String(p.cwd ?? ""),
95
+ session:
96
+ s && (s.kind === "id" || s.kind === "path") && typeof s.value === "string"
97
+ ? { kind: s.kind, value: s.value }
98
+ : undefined,
99
+ });
100
+ }
101
+ const byId = (list: unknown, key: string) =>
102
+ new Map<string, { label: string; number: number }>(
103
+ (Array.isArray(list) ? list : [])
104
+ .filter((x: any) => typeof x?.[key] === "string")
105
+ .map((x: any) => [x[key], { label: String(x.label ?? ""), number: Number(x.number ?? 0) }]),
106
+ );
107
+ return {
108
+ panes,
109
+ focusedWorkspaceId: typeof snap.focused_workspace_id === "string" ? snap.focused_workspace_id : undefined,
110
+ tabs: byId(snap.tabs, "tab_id"),
111
+ workspaces: byId(snap.workspaces, "workspace_id"),
112
+ };
113
+ }
114
+
115
+ /** Compare paths across the formats agents store (`C:/x`, `C:\x`, lossy case). */
116
+ export function samePath(a: string, b: string): boolean {
117
+ const norm = (p: string) => {
118
+ let s = p.replaceAll("\\", "/").replace(/\/+$/, "");
119
+ if (/^[A-Za-z]:/.test(s)) s = s.toLowerCase();
120
+ return s;
121
+ };
122
+ if (!a || !b) return false;
123
+ const x = norm(a);
124
+ const y = norm(b);
125
+ return x === y || x.toLowerCase() === y.toLowerCase();
126
+ }
127
+
128
+ const STATUS_FROM_HERDR: Partial<Record<HerdrAgentStatus, AgentStatus>> = {
129
+ working: "live-busy",
130
+ blocked: "live-blocked",
131
+ done: "live-done",
132
+ idle: "live-idle",
133
+ };
134
+
135
+ /**
136
+ * Attach herdr links to sessions and let herdr's state win where it knows
137
+ * it. Panes reporting a session id/path are matched first; panes without one
138
+ * fall back to "most recent session of that agent in that directory".
139
+ */
140
+ export function linkHerdrSessions(
141
+ sessions: AgentSession[],
142
+ snap: HerdrSnapshot | undefined,
143
+ ): AgentSession[] {
144
+ if (!snap || snap.panes.length === 0) return sessions;
145
+ const links = new Map<AgentSession, HerdrLink>();
146
+ const link = (s: AgentSession, p: HerdrPane, matchedBy: HerdrLink["matchedBy"]) => {
147
+ const tab = snap.tabs.get(p.tabId);
148
+ links.set(s, {
149
+ paneId: p.paneId,
150
+ tabId: p.tabId,
151
+ workspaceId: p.workspaceId,
152
+ workspaceLabel: snap.workspaces.get(p.workspaceId)?.label ?? "",
153
+ tabLabel: tab?.label ?? "",
154
+ tabNumber: tab?.number ?? 0,
155
+ status: p.status,
156
+ matchedBy,
157
+ agent: p.agent ?? "",
158
+ });
159
+ };
160
+ const agentPanes = snap.panes.filter((p) => p.agent && PROVIDER_OF[p.agent]);
161
+
162
+ for (const p of agentPanes) {
163
+ if (!p.session) continue;
164
+ const provider = PROVIDER_OF[p.agent!];
165
+ const { kind, value } = p.session;
166
+ const s = sessions.find(
167
+ (x) =>
168
+ x.provider === provider &&
169
+ !links.has(x) &&
170
+ (kind === "id"
171
+ ? x.sessionId === value
172
+ : samePath(x.sourcePath, value) || value.includes(x.sessionId)),
173
+ );
174
+ if (s) link(s, p, kind);
175
+ }
176
+ for (const p of agentPanes) {
177
+ if (p.session) continue;
178
+ const provider = PROVIDER_OF[p.agent!];
179
+ const s = sessions
180
+ .filter((x) => x.provider === provider && !links.has(x) && samePath(x.cwd, p.cwd))
181
+ .sort((a, b) => b.lastActivityMs - a.lastActivityMs)[0];
182
+ if (s) link(s, p, "cwd");
183
+ }
184
+
185
+ if (links.size === 0) return sessions;
186
+ return sessions.map((s) => {
187
+ const l = links.get(s);
188
+ if (!l) return s;
189
+ return { ...s, herdr: l, status: STATUS_FROM_HERDR[l.status] ?? s.status };
190
+ });
191
+ }
192
+
193
+ /** Agent status glyph style — the same two herdr offers. */
194
+ export type IndicatorStyle = "dots" | "symbols";
195
+
196
+ /** One synchronous snapshot, for CLI/dev scripts. */
197
+ export function readHerdrSnapshotOnce(bin = Bun.which("herdr") ?? undefined): HerdrSnapshot | undefined {
198
+ if (!bin) return undefined;
199
+ try {
200
+ const res = Bun.spawnSync([bin, "api", "snapshot"], { stdout: "pipe", stderr: "ignore", timeout: 5000 });
201
+ return res.exitCode === 0 ? parseHerdrSnapshot(res.stdout.toString()) : undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ // ─── Polling source ─────────────────────────────────────────────────────────
208
+
209
+ export interface HerdrSource {
210
+ snapshot: () => HerdrSnapshot | undefined;
211
+ onChange: (cb: () => void) => void;
212
+ dispose: () => void;
213
+ }
214
+
215
+ export const HERDR_POLL_MS = 2000;
216
+
217
+ /**
218
+ * Poll `herdr api snapshot` in the background. Inert when herdr isn't on
219
+ * PATH (or `bin: null`). A failing call clears the snapshot (server gone)
220
+ * without noise.
221
+ */
222
+ export function createHerdrSource(
223
+ { pollMs = HERDR_POLL_MS, bin = Bun.which("herdr") }: { pollMs?: number; bin?: string | null } = {},
224
+ ): HerdrSource {
225
+ let current: HerdrSnapshot | undefined;
226
+ let lastRaw = "";
227
+ const listeners: (() => void)[] = [];
228
+ if (!bin) return { snapshot: () => undefined, onChange: () => {}, dispose: () => {} };
229
+
230
+ let inFlight = false;
231
+ let disposed = false;
232
+ const poll = async () => {
233
+ if (inFlight || disposed) return;
234
+ inFlight = true;
235
+ try {
236
+ const proc = Bun.spawn([bin, "api", "snapshot"], {
237
+ stdout: "pipe",
238
+ stderr: "ignore",
239
+ windowsHide: true,
240
+ });
241
+ const timer = setTimeout(() => proc.kill(), 5000);
242
+ const out = await new Response(proc.stdout).text();
243
+ clearTimeout(timer);
244
+ const code = await proc.exited;
245
+ const snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
246
+ // Only the parts we use decide whether anything changed.
247
+ const key = snap
248
+ ? JSON.stringify([snap.panes, [...snap.tabs], [...snap.workspaces]])
249
+ : "";
250
+ if (key !== lastRaw && !disposed) {
251
+ lastRaw = key;
252
+ current = snap;
253
+ for (const cb of listeners) cb();
254
+ }
255
+ } catch {
256
+ // herdr vanished mid-call — try again next tick
257
+ } finally {
258
+ inFlight = false;
259
+ }
260
+ };
261
+ void poll();
262
+ const timer = setInterval(poll, pollMs);
263
+ timer.unref?.();
264
+ return {
265
+ snapshot: () => current,
266
+ onChange: (cb) => listeners.push(cb),
267
+ dispose: () => {
268
+ disposed = true;
269
+ clearInterval(timer);
270
+ },
271
+ };
272
+ }
@@ -29,7 +29,16 @@ 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";
41
+ import { createHerdrSource } from "./herdr";
33
42
  import {
34
43
  createCalendarStore,
35
44
  createGoogleEvent,
@@ -259,6 +268,10 @@ export interface UIState {
259
268
  marked: Record<string, true>;
260
269
  /** Active filter. */
261
270
  filter: "all" | "today" | "overdue" | "tomorrow" | "followup";
271
+ /** Agents-zone harness filter (`f` while the Agents zone is active). */
272
+ agentsFilter: AgentsFilter;
273
+ /** Outcome of the last Enter (open session), shown in that session's detail. */
274
+ lastLaunch?: { sessionId: string; ok: boolean; text: string; ts: number };
262
275
  /** Banner messages (errors, conflicts, undo notifications). */
263
276
  banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
264
277
  /** Open modal, if any. Keyboard handler routes input to the modal when set. */
@@ -349,6 +362,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
349
362
  view: "kanban",
350
363
  marked: {},
351
364
  filter: "all",
365
+ agentsFilter: "all",
352
366
  },
353
367
  undo: [],
354
368
  rev: 0,
@@ -362,12 +376,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
362
376
  initialBoards.map((b) => b.board.filepath),
363
377
  );
364
378
 
365
- // Agents store has its own lifecycle (chokidar watcher on ~/.claude).
366
- // Shared dispose() boundary below so SIGINT cleans both. When the agents
367
- // zone is disabled we skip the watcher entirely (no `~/.claude` reads at all)
368
- // and hand back an inert stub.
379
+ // Agents store has its own lifecycle (chokidar watcher on each agent CLI's
380
+ // session dirs). Shared dispose() boundary below so SIGINT cleans both. When
381
+ // the agents zone is disabled we skip the watcher entirely (no session reads
382
+ // at all) and hand back an inert stub.
369
383
  const agentsStore: AgentsStore = enabledZones.agents
370
- ? createAgentsStore()
384
+ ? createAgentsStore(AGENT_ADAPTERS, { herdr: createHerdrSource() })
371
385
  : noopAgentsStore();
372
386
  // Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
373
387
  // (no network) when the agenda zone is disabled.
@@ -1230,6 +1244,28 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1230
1244
  setState("ui", "zoomed", (z: boolean) => !z);
1231
1245
  }
1232
1246
 
1247
+ /**
1248
+ * Sessions the Agents zone lists, after the harness filter. The dashboard
1249
+ * strip additionally hides archived ones — they sort last, so row indices
1250
+ * into this list stay valid for both views.
1251
+ */
1252
+ function agentSessions(): AgentSession[] {
1253
+ return filterSessions(agentsStore.sessions(), state.ui.agentsFilter);
1254
+ }
1255
+
1256
+ function setLastLaunch(sessionId: string, ok: boolean, text: string): void {
1257
+ setState("ui", "lastLaunch", { sessionId, ok, text, ts: Date.now() });
1258
+ }
1259
+
1260
+ /** Cycle the Agents-zone filter: all → cc → cx → oc → pi → all. */
1261
+ function cycleAgentsFilter(): AgentsFilter {
1262
+ const cycle: AgentsFilter[] = ["all", ...(Object.keys(HARNESS) as AgentsFilter[])];
1263
+ const next = cycle[(cycle.indexOf(state.ui.agentsFilter) + 1) % cycle.length]!;
1264
+ setState("ui", "agentsFilter", next);
1265
+ setState("ui", "row", 0);
1266
+ return next;
1267
+ }
1268
+
1233
1269
  function setFilter(f: UIState["filter"]): void {
1234
1270
  setState("ui", "filter", f);
1235
1271
  // The cursor's row was an index into the unfiltered list — reset to top
@@ -1308,7 +1344,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1308
1344
 
1309
1345
  /**
1310
1346
  * Manual full refresh (the `r` key). Re-reads every board from disk, rescans
1311
- * Claude Code agents, and force-refetches the Agenda's calendar (bypassing
1347
+ * agent sessions, and force-refetches the Agenda's calendar (bypassing
1312
1348
  * the 30-min cache). Lets the user pull in external changes — a calendar
1313
1349
  * event edited in the browser, a board touched elsewhere — without leaving
1314
1350
  * tuiboard. Boards normally auto-reload via the file watcher; this also
@@ -1696,6 +1732,10 @@ export function createTuiStore({ config }: CreateStoreOptions) {
1696
1732
  config,
1697
1733
  activeBoard,
1698
1734
  agents: agentsStore,
1735
+ agentSessions,
1736
+ agentIndicators: config.statusIndicators,
1737
+ cycleAgentsFilter,
1738
+ setLastLaunch,
1699
1739
  calendar: calendarStore,
1700
1740
  // queries
1701
1741
  getBoardByPath,
@@ -1781,7 +1821,7 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
1781
1821
  /** Inert agents store for when the agents zone is disabled — no chokidar
1782
1822
  * watcher, no `~/.claude` reads at all. */
1783
1823
  function noopAgentsStore(): AgentsStore {
1784
- return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
1824
+ return { sessions: () => [], herdr: () => undefined, refresh: () => {}, dispose: async () => {} };
1785
1825
  }
1786
1826
 
1787
1827
  /** Inert calendar store for when the agenda zone is disabled — no fetching. */
@@ -1,36 +1,95 @@
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 { herdrPlace, type IndicatorStyle } from "~/store/herdr";
20
+ import {
21
+ HARNESS,
22
+ cwdShort,
23
+ formatAge,
24
+ shortModel,
25
+ type AgentProvider,
26
+ type AgentSession,
27
+ type AgentStatus,
28
+ } from "~/store/agents";
12
29
 
13
30
  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,
31
+ "live-blocked": T.overdue, // needs you
32
+ "live-busy": T.today, // bright accent for actively-running
33
+ "live-done": T.done, // finished, not looked at yet
34
+ "live-idle": T.scheduled, // warm but quieter
35
+ "stale": T.bannerWarn,
36
+ "dormant": T.textDim,
37
+ "archived": T.textDone,
19
38
  };
20
39
 
21
- const STATUS_GLYPH: Record<AgentStatus, string> = {
22
- "live-busy": "●",
23
- "live-idle": "○",
24
- "stale-pid": "△",
25
- "dormant": "·",
26
- "archived": "·",
40
+ /**
41
+ * herdr's two `status_indicators` styles, copied as-is so the same state reads
42
+ * the same in both tools. `stale` is tuiboard's own (no herdr equivalent).
43
+ */
44
+ const STATUS_GLYPH: Record<IndicatorStyle, Record<AgentStatus, string>> = {
45
+ dots: {
46
+ "live-blocked": "●",
47
+ "live-busy": "●",
48
+ "live-done": "●",
49
+ "live-idle": "○",
50
+ "stale": "△",
51
+ "dormant": "·",
52
+ "archived": "·",
53
+ },
54
+ symbols: {
55
+ "live-blocked": "×",
56
+ "live-busy": "◐",
57
+ "live-done": "✓",
58
+ "live-idle": "○",
59
+ "stale": "△",
60
+ "dormant": "·",
61
+ "archived": "·",
62
+ },
63
+ };
64
+
65
+
66
+
67
+ /** One hue per harness so the badge reads before the letters do. */
68
+ export const HARNESS_COLOR: Record<AgentProvider, string> = {
69
+ "claude-code": T.warm, // orange
70
+ codex: T.accent, // blue-cyan
71
+ opencode: "#d27ee0", // violet-fuchsia
72
+ pi: "#c3d94e", // acid yellow-green
27
73
  };
28
74
 
75
+ /** Card cwd: `~`-relative when under $HOME, shortened when still long. */
76
+ const HOME = homedir();
77
+ const CARD_CWD_MAX = 60;
78
+ function cardCwd(cwd: string): string {
79
+ const underHome = cwd === HOME || cwd.startsWith(HOME + "/") || cwd.startsWith(HOME + "\\");
80
+ const shown = underHome ? "~" + cwd.slice(HOME.length) : cwd;
81
+ return shown.length > CARD_CWD_MAX ? cwdShort(cwd) : shown;
82
+ }
83
+
29
84
  interface AgentRowProps {
30
85
  session: AgentSession;
31
86
  cursor?: boolean;
32
87
  /** Maximum chars for displayName before truncation. Default 40. */
33
88
  nameMaxChars?: number;
89
+ /** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
90
+ variant?: "line" | "card";
91
+ /** Status glyph style (default symbols, as in herdr). */
92
+ indicators?: IndicatorStyle;
34
93
  onClick?: () => void;
35
94
  }
36
95
 
@@ -43,42 +102,121 @@ export function AgentRow(props: AgentRowProps) {
43
102
  const n = props.session.displayName;
44
103
  return n.length > nameMax() ? n.slice(0, nameMax() - 1) + "…" : n;
45
104
  });
105
+ const model = createMemo(() => shortModel(props.session.model));
106
+
107
+ // Measured row width; undefined until the first layout pass.
108
+ const [width, setWidth] = createSignal<number | undefined>();
109
+ let rowRef: { width: number } | undefined;
110
+ const measure = () => {
111
+ if (rowRef && rowRef.width > 0) setWidth(rowRef.width);
112
+ };
113
+ onMount(() => setTimeout(measure, 0));
114
+ const sizeProps = {
115
+ ref: (r: { width: number }) => (rowRef = r),
116
+ onSizeChange: measure,
117
+ };
118
+
119
+ const line = createMemo(() =>
120
+ layoutLine(
121
+ {
122
+ name: displayName(),
123
+ branch: props.session.gitBranch,
124
+ model: model(),
125
+ cwd: props.session.cwdShort,
126
+ age: ageStr().padStart(3),
127
+ },
128
+ width(),
129
+ ),
130
+ );
131
+ const cardName = createMemo(() => layoutCardName(displayName(), width()));
132
+ const details = createMemo(() =>
133
+ layoutCardDetails(
134
+ [
135
+ model(),
136
+ props.session.herdr && herdrPlace(props.session.herdr),
137
+ props.session.gitBranch,
138
+ cardCwd(props.session.cwd),
139
+ ],
140
+ width(),
141
+ ),
142
+ );
143
+
144
+ // cursor · status glyph · harness badge — shared by both variants.
145
+ const lead = () => [
146
+ <span style={{ fg: props.cursor ? T.accent : T.textDim }}>
147
+ {props.cursor ? "▶ " : " "}
148
+ </span>,
149
+ <span style={{ fg: STATUS_COLOR[props.session.status] }}>
150
+ {STATUS_GLYPH[props.indicators ?? "symbols"][props.session.status]}{" "}
151
+ </span>,
152
+ <span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
153
+ {HARNESS[props.session.provider].code}{" "}
154
+ </span>,
155
+ ];
46
156
 
47
157
  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}
158
+ <Show
159
+ when={props.variant === "card"}
160
+ fallback={
161
+ <box
162
+ {...sizeProps}
163
+ style={{
164
+ flexDirection: "row",
165
+ paddingLeft: 1,
166
+ paddingRight: 1,
167
+ backgroundColor: props.cursor ? T.cardBgCursor : undefined,
168
+ }}
169
+ onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
170
+ >
171
+ {/* `truncate` stays on as a safety net only — layoutLine already
172
+ fits the fields to the measured width with a tail cut, while
173
+ OpenTUI's own truncation would elide mid-string. */}
174
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
175
+ {lead()}
176
+ <span style={{ fg: T.text }}>{line().name}</span>
177
+ <Show when={line().branch}>
178
+ <span style={{ fg: T.warmDim }}>{" "}{line().branch}</span>
179
+ </Show>
180
+ <Show when={line().model}>
181
+ <span style={{ fg: T.textDim }}>{" "}{line().model}</span>
182
+ </Show>
183
+ </text>
184
+ {/* cwd + age pinned together on the right. The age is right-aligned in a
185
+ fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
186
+ cwd lands on the same column across rows — a 1- vs 2-digit age no
187
+ longer shoves the directory names out of vertical alignment. */}
188
+ <text style={{ flexShrink: 0 }} wrapMode="none">
189
+ <span style={{ fg: T.textDim }}>{" "}{line().right}</span>
190
+ </text>
191
+ </box>
192
+ }
56
193
  >
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>
194
+ <box
195
+ {...sizeProps}
196
+ style={{
197
+ flexDirection: "column",
198
+ paddingLeft: 1,
199
+ paddingRight: 1,
200
+ backgroundColor: props.cursor ? T.cardBgCursor : undefined,
201
+ }}
202
+ onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
203
+ >
204
+ <box style={{ flexDirection: "row" }}>
205
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
206
+ {lead()}
207
+ <span style={{ fg: T.text }}>{cardName()}</span>
208
+ </text>
209
+ <text style={{ flexShrink: 0 }} wrapMode="none">
210
+ <span style={{ fg: T.textDim }}>{" "}{ageStr().padStart(3)}</span>
211
+ </text>
212
+ </box>
213
+ {/* Indent = width of "▶ ● cc " so the details line up under the name. */}
214
+ <box style={{ flexDirection: "row", paddingLeft: 7 }}>
215
+ <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
216
+ <span style={{ fg: T.textDim }}>{details()}</span>
217
+ </text>
218
+ </box>
219
+ </box>
220
+ </Show>
83
221
  );
84
222
  }