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.
package/src/ui/Modal.tsx CHANGED
@@ -11,7 +11,7 @@
11
11
  * checks `ui.modal` first and bails if set (only Escape passes through).
12
12
  */
13
13
 
14
- import { For, Show, createContext, createMemo, createSignal, useContext } from "solid-js";
14
+ import { For, Show, createContext, createEffect, createMemo, createSignal, useContext } from "solid-js";
15
15
 
16
16
  import { isTask } from "~/parser/markdown";
17
17
  import {
@@ -19,9 +19,11 @@ import {
19
19
  parseQuickAdd,
20
20
  parseTimeBlockShortcut,
21
21
  } from "~/store/parsers";
22
- import { ATTR, T } from "~/ui/glyphs";
22
+ import { ATTR, T, cellWidth } from "~/ui/glyphs";
23
23
  import { AGENDA_WIDTH } from "~/ui/layout";
24
24
  import { formatHm } from "~/store/timeline";
25
+ import { HARNESS } from "~/store/agents";
26
+ import { HARNESS_COLOR } from "~/ui/AgentRow";
25
27
  import type { TuiStore } from "~/store/index";
26
28
  import type { PriorityLevel, TimeBlock } from "~/types";
27
29
 
@@ -919,6 +921,17 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
919
921
  <span style={{ fg: T.textDim }}>session </span>
920
922
  <span style={{ fg: T.accent }}>{s().sessionId}</span>
921
923
  </text>
924
+ <text>
925
+ <span style={{ fg: T.textDim }}>harness </span>
926
+ <span style={{ fg: HARNESS_COLOR[s().provider] }}>{HARNESS[s().provider].code}</span>
927
+ <span style={{ fg: T.text }}>{" "}{HARNESS[s().provider].name}</span>
928
+ </text>
929
+ <Show when={s().model}>
930
+ <text>
931
+ <span style={{ fg: T.textDim }}>model </span>
932
+ <span style={{ fg: T.text }}>{s().model}</span>
933
+ </text>
934
+ </Show>
922
935
  <text>
923
936
  <span style={{ fg: T.textDim }}>status </span>
924
937
  <span style={{ fg: T.text }}>{s().status}</span>
@@ -973,17 +986,37 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
973
986
  </span>
974
987
  </text>
975
988
  </Show>
989
+ <Show
990
+ when={
991
+ props.store.state.ui.lastLaunch?.sessionId === s().sessionId
992
+ ? props.store.state.ui.lastLaunch
993
+ : undefined
994
+ }
995
+ >
996
+ {(l: () => NonNullable<TuiStore["state"]["ui"]["lastLaunch"]>) => (
997
+ <>
998
+ <box style={{ height: 1 }} />
999
+ <text>
1000
+ <span style={{ fg: T.textDim }}>last Enter ({new Date(l().ts).toLocaleTimeString()}):</span>
1001
+ </text>
1002
+ <text wrapMode="word">
1003
+ <span style={{ fg: l().ok ? T.done : T.overdue }}>{l().text}</span>
1004
+ </text>
1005
+ </>
1006
+ )}
1007
+ </Show>
976
1008
  <box style={{ height: 1 }} />
977
1009
  <text>
978
1010
  <span style={{ fg: T.textDim }}>
979
- resume — Enter opens this in WezTerm; c copies this command to paste anywhere:
1011
+ resume — Enter opens this in a new terminal tab; c copies this command to paste anywhere:
980
1012
  </span>
981
1013
  </text>
982
1014
  <text wrapMode="word">
983
1015
  <span style={{ fg: T.scheduled }}>
984
1016
  {props.store.config.copyResumeCommand
985
1017
  .replaceAll("{cwd}", s().cwd)
986
- .replaceAll("{sessionId}", s().sessionId)}
1018
+ .replaceAll("{sessionId}", s().sessionId)
1019
+ .replaceAll("{resume}", s().resumeCommand)}
987
1020
  </span>
988
1021
  </text>
989
1022
  </box>
@@ -1001,72 +1034,234 @@ function truncate(s: string, n: number): string {
1001
1034
 
1002
1035
  // ─── Help ────────────────────────────────────────────────────────────────────
1003
1036
 
1037
+ /**
1038
+ * Width (cells) of the key column. The longest key label is 15 cells
1039
+ * ("shift+click row"), so 16 leaves room for a one-cell separator and keeps
1040
+ * every description starting at the same column. `cellWidth` (not `.length`)
1041
+ * is used so wide arrow/emoji glyphs are budgeted correctly.
1042
+ */
1043
+ const HELP_KEYW = 16;
1044
+
1045
+ interface HelpSection {
1046
+ emoji: string;
1047
+ title: string;
1048
+ /** Scope note shown dim under the header (kept verbatim from the original). */
1049
+ note?: string;
1050
+ /** [keyLabel, description] pairs — descriptions are verbatim. */
1051
+ rows: [string, string][];
1052
+ }
1053
+
1054
+ const HELP_SECTIONS: HelpSection[] = [
1055
+ {
1056
+ emoji: "🧭",
1057
+ title: "Navigation",
1058
+ rows: [
1059
+ ["h j k l ←↑↓→", "Move cursor inside the active zone"],
1060
+ ["Tab", "Next board (kanban zone)"],
1061
+ ["+", "New board — create one, or adopt files you have"],
1062
+ ["1..9", "Jump to board N"],
1063
+ ["v", "Toggle Today/Tomorrow planner panel focus"],
1064
+ ["Shift-Tab", "Cycle active zone (planner → board → timeline → agents)"],
1065
+ ["F1 / F2 / F3", "Toggle visibility of Planner / Timeline / Agents zones"],
1066
+ ["z", "Zoom active zone (or column) to full screen"],
1067
+ ["r", "Refresh everything (boards from disk, agents, agenda calendar)"],
1068
+ ],
1069
+ },
1070
+ {
1071
+ emoji: "⚙️",
1072
+ title: "Task actions",
1073
+ note: "work in board, planner, AND timeline zones",
1074
+ rows: [
1075
+ ["Enter", "Toggle done"],
1076
+ ["o", "Open detail view"],
1077
+ ["e", "Edit task text"],
1078
+ ["s", "Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)"],
1079
+ ["t", "Set scheduled = today"],
1080
+ ["m", "Set scheduled = tomorrow"],
1081
+ [".", "Schedule now — time block at next 15-min slot (30min)"],
1082
+ ["b", "Set time block modal"],
1083
+ ["p", "Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)"],
1084
+ ["a", "Set assignee"],
1085
+ ["d", "Delete task (with confirm)"],
1086
+ ["X", "Archive task → moves to Archive column"],
1087
+ ["C", "Copy task to clipboard (markdown line)"],
1088
+ ],
1089
+ },
1090
+ {
1091
+ emoji: "📅",
1092
+ title: "Agenda — day navigation",
1093
+ note: "works from any zone",
1094
+ rows: [
1095
+ ["[ / ]", "Previous / next day (tasks + calendar events)"],
1096
+ ["\\", "Jump back to today"],
1097
+ ],
1098
+ },
1099
+ {
1100
+ emoji: "⏰",
1101
+ title: "Agenda — scheduling",
1102
+ rows: [
1103
+ ["n / click slot", "New Google Calendar event (needs: calendar-setup google --write) append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday"],
1104
+ ["click an event", "Select an editable Google event — then e edit · d delete · Esc"],
1105
+ ["c (any zone)", "Toggle ARM MODE — then click a task, click a slot, repeat"],
1106
+ ["click empty row", "Place the armed task here (30-min block, or move if it has one)"],
1107
+ ["click band", "Arm an existing block (or place the armed task at its start)"],
1108
+ ["shift+click row", "While armed (existing block): resize end to that row"],
1109
+ ["j / k", "While armed: nudge block ±15 min"],
1110
+ ["+ / -", "While armed: resize block end ±15 min"],
1111
+ ["Enter", "While armed: commit + jump to source task"],
1112
+ ["Esc", "Disarm / exit arm mode"],
1113
+ ],
1114
+ },
1115
+ {
1116
+ emoji: "📋",
1117
+ title: "Board-only actions",
1118
+ rows: [
1119
+ ["n", "New task in current column (quick-add syntax)"],
1120
+ ["g", "Grab task — h/l then moves it between columns; g/Esc to drop"],
1121
+ ["f", "Cycle board filter: all → today → overdue → tomorrow → followup (Agents zone: harness filter)"],
1122
+ ["/", "Search task titles — jumps cursor to first match"],
1123
+ ],
1124
+ },
1125
+ {
1126
+ emoji: "🤖",
1127
+ title: "Agents zone",
1128
+ rows: [
1129
+ ["Enter", "Open (resume) the selected session in a new terminal tab/window"],
1130
+ ["c", "Copy a 'cd + resume' command for the session"],
1131
+ ["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
1132
+ ["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
1133
+ ],
1134
+ },
1135
+ {
1136
+ emoji: "☑️",
1137
+ title: "Multi-select",
1138
+ rows: [
1139
+ ["Space", "Mark / unmark task (cursor stays — mark in any order) Every task action (done/schedule/time block/assign/priority/archive/delete) then applies to ALL marked"],
1140
+ ["Esc", "Clear the selection (when no modal is open)"],
1141
+ ],
1142
+ },
1143
+ {
1144
+ emoji: "📦",
1145
+ title: "Bulk",
1146
+ rows: [["T", "Reset ALL overdue tasks (any board) to today"]],
1147
+ },
1148
+ {
1149
+ emoji: "🌐",
1150
+ title: "Global",
1151
+ rows: [
1152
+ ["Ctrl-Z", "Undo last mutation"],
1153
+ ["?", "This help"],
1154
+ ["q · Ctrl-C", "Quit"],
1155
+ ],
1156
+ },
1157
+ ];
1158
+
1159
+ /**
1160
+ * Flattened render+scroll units: one "header" block per section (carrying its
1161
+ * optional note) followed by one "row" block per shortcut. `ui.helpScroll` is
1162
+ * an index into this list; `j`/`k` step it and the matching `<box id>` is
1163
+ * scrolled into view.
1164
+ */
1165
+ type HelpBlock =
1166
+ | { kind: "header"; emoji: string; title: string; note?: string }
1167
+ | { kind: "row"; keyLabel: string; desc: string };
1168
+
1169
+ const HELP_BLOCKS: HelpBlock[] = HELP_SECTIONS.flatMap((s) => [
1170
+ { kind: "header", emoji: s.emoji, title: s.title, note: s.note } as HelpBlock,
1171
+ ...s.rows.map(([keyLabel, desc]) => ({ kind: "row", keyLabel, desc }) as HelpBlock),
1172
+ ]);
1173
+
1174
+ /** Dim dot leader that pads `key` out to the fixed key column (with one leading
1175
+ * separator space). Empty for keys that already fill the column. */
1176
+ function helpDots(key: string): string {
1177
+ const n = HELP_KEYW - cellWidth(key) - 1;
1178
+ return n > 0 ? "·".repeat(n) : "";
1179
+ }
1180
+
1181
+ /** Minimal structural view of OpenTUI's <scrollbox> ref (mirrors TimelineView). */
1182
+ interface ScrollBoxLike {
1183
+ scrollChildIntoView(id: string): void;
1184
+ }
1185
+
1186
+ /**
1187
+ * Keyboard reference.
1188
+ *
1189
+ * Each shortcut is a SINGLE full-width `<text wrapMode="word">` (key + dim dot
1190
+ * leader + description as spans). A single full-width text manages its own
1191
+ * wrapped height correctly; a wrapping `<text>` placed as a flex *sibling* in a
1192
+ * row does NOT (the next row overpaints it — the "bleed-into-neighbor" glitch
1193
+ * documented in TaskRow). When a description wraps it returns to column 0 in the
1194
+ * dim description color, visually distinct from the accent key.
1195
+ *
1196
+ * The whole list lives in a `<scrollbox>` so it never clips on short terminals;
1197
+ * `j`/`k` move `ui.helpScroll` and the focused block is scrolled into view.
1198
+ */
1004
1199
  function HelpModal(props: { store: TuiStore }) {
1005
- void props;
1200
+ let scrollBoxRef: ScrollBoxLike | undefined;
1201
+
1202
+ createEffect(() => {
1203
+ const raw = props.store.state.ui.helpScroll;
1204
+ const clamped = Math.max(0, Math.min(raw, HELP_BLOCKS.length - 1));
1205
+ if (clamped !== raw) {
1206
+ props.store.setHelpScroll(clamped);
1207
+ return; // effect re-runs with the corrected value
1208
+ }
1209
+ scrollBoxRef?.scrollChildIntoView(`help-b${clamped}`);
1210
+ });
1211
+
1006
1212
  return (
1007
- <DialogShell title="tuiboard — keyboard reference" hint="Esc/? to close" width={92}>
1008
- <text>
1009
- <span style={{ fg: T.textDim }}>{"Navigation\n"}</span>
1010
- <span style={{ fg: T.text }}>{" h j k l ←↑↓→ Move cursor inside the active zone\n"}</span>
1011
- <span style={{ fg: T.text }}>{" Tab Next board (kanban zone)\n"}</span>
1012
- <span style={{ fg: T.text }}>{" + New board — create one, or adopt files you have\n"}</span>
1013
- <span style={{ fg: T.text }}>{" z Focus one pane (automatic below 100 columns)\n"}</span>
1014
- <span style={{ fg: T.text }}>{" 1..9 Jump to board N\n"}</span>
1015
- <span style={{ fg: T.text }}>{" v Toggle Today/Tomorrow planner panel focus\n"}</span>
1016
- <span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (planner → board → timeline → agents)\n"}</span>
1017
- <span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Planner / Timeline / Agents zones\n"}</span>
1018
- <span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
1019
- <span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
1020
- <span style={{ fg: T.textDim }}>{"\nTask actions (work in board, planner, AND timeline zones)\n"}</span>
1021
- <span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
1022
- <span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
1023
- <span style={{ fg: T.text }}>{" e Edit task text\n"}</span>
1024
- <span style={{ fg: T.text }}>{" s Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)\n"}</span>
1025
- <span style={{ fg: T.text }}>{" t Set scheduled = today\n"}</span>
1026
- <span style={{ fg: T.text }}>{" m Set scheduled = tomorrow\n"}</span>
1027
- <span style={{ fg: T.text }}>{" . Schedule now — time block at next 15-min slot (30min)\n"}</span>
1028
- <span style={{ fg: T.text }}>{" b Set time block modal\n"}</span>
1029
- <span style={{ fg: T.text }}>{" p Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)\n"}</span>
1030
- <span style={{ fg: T.text }}>{" a Set assignee\n"}</span>
1031
- <span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
1032
- <span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
1033
- <span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
1034
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) day navigation — works from any zone\n"}</span>
1035
- <span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
1036
- <span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
1037
- <span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
1038
- <span style={{ fg: T.text }}>{" n / click slot New Google Calendar event (needs: calendar-setup google --write)\n"}</span>
1039
- <span style={{ fg: T.text }}>{" append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday\n"}</span>
1040
- <span style={{ fg: T.text }}>{" click an event Select an editable Google event — then e edit · d delete · Esc\n"}</span>
1041
- <span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
1042
- <span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
1043
- <span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
1044
- <span style={{ fg: T.text }}>{" shift+click row While armed (existing block): resize end to that row\n"}</span>
1045
- <span style={{ fg: T.text }}>{" j / k While armed: nudge block ±15 min\n"}</span>
1046
- <span style={{ fg: T.text }}>{" + / - While armed: resize block end ±15 min\n"}</span>
1047
- <span style={{ fg: T.text }}>{" Enter While armed: commit + jump to source task\n"}</span>
1048
- <span style={{ fg: T.text }}>{" Esc Disarm / exit arm mode\n"}</span>
1049
- <span style={{ fg: T.textDim }}>{"\nBoard-only actions\n"}</span>
1050
- <span style={{ fg: T.text }}>{" n New task in current column (quick-add syntax)\n"}</span>
1051
- <span style={{ fg: T.text }}>{" g Grab task — h/l then moves it between columns; g/Esc to drop\n"}</span>
1052
- <span style={{ fg: T.text }}>{" f Cycle board filter: all → today → overdue → tomorrow → followup\n"}</span>
1053
- <span style={{ fg: T.text }}>{" / Search task titles — jumps cursor to first match\n"}</span>
1054
- <span style={{ fg: T.textDim }}>{"\nAgents zone\n"}</span>
1055
- <span style={{ fg: T.text }}>{" Enter Open (resume) the selected session in a new WezTerm tab\n"}</span>
1056
- <span style={{ fg: T.text }}>{" c Copy a 'cd + claude --resume' command for the session\n"}</span>
1057
- <span style={{ fg: T.text }}>{" o Session detail (cwd, branch, last prompts, resume cmd)\n"}</span>
1058
- <span style={{ fg: T.textDim }}>{"\nMulti-select\n"}</span>
1059
- <span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
1060
- <span style={{ fg: T.text }}>{" Every task action (done/schedule/time block/assign/\n"}</span>
1061
- <span style={{ fg: T.text }}>{" priority/archive/delete) then applies to ALL marked\n"}</span>
1062
- <span style={{ fg: T.text }}>{" Esc Clear the selection (when no modal is open)\n"}</span>
1063
- <span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
1064
- <span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
1065
- <span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
1066
- <span style={{ fg: T.text }}>{" Ctrl-Z Undo last mutation\n"}</span>
1067
- <span style={{ fg: T.text }}>{" ? This help\n"}</span>
1068
- <span style={{ fg: T.text }}>{" q · Ctrl-C Quit\n"}</span>
1069
- </text>
1213
+ <DialogShell title="tuiboard — keyboard reference" hint="Esc/? close · j/k scroll" width={92}>
1214
+ <scrollbox
1215
+ ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
1216
+ style={{
1217
+ width: "100%",
1218
+ flexGrow: 1,
1219
+ scrollX: false,
1220
+ scrollY: true,
1221
+ rootOptions: {},
1222
+ contentOptions: {},
1223
+ scrollbarOptions: { visible: false },
1224
+ }}
1225
+ >
1226
+ <For each={HELP_BLOCKS}>
1227
+ {(block, i) => (
1228
+ <box id={`help-b${i()}`} style={{ flexDirection: "column" }}>
1229
+ <Show when={block.kind === "header"}>
1230
+ {/* blank line above every section header except the first */}
1231
+ <Show when={i() > 0}>
1232
+ <box style={{ height: 1 }} />
1233
+ </Show>
1234
+ <text wrapMode="none" truncate>
1235
+ <span style={{ fg: T.warm, attributes: ATTR.bold }}>
1236
+ {`${(block as { emoji: string }).emoji} ${(block as { title: string }).title}`}
1237
+ </span>
1238
+ </text>
1239
+ {/* scope note on its own wrapping line, kept verbatim */}
1240
+ <Show when={(block as { note?: string }).note}>
1241
+ <text wrapMode="word">
1242
+ <span style={{ fg: T.textDone, attributes: ATTR.italic }}>
1243
+ {(block as { note: string }).note}
1244
+ </span>
1245
+ </text>
1246
+ </Show>
1247
+ </Show>
1248
+ <Show when={block.kind === "row"}>
1249
+ <text wrapMode="word">
1250
+ <span style={{ fg: T.accent, attributes: ATTR.bold }}>
1251
+ {(block as { keyLabel: string }).keyLabel}
1252
+ </span>
1253
+ <span style={{ fg: T.border }}>
1254
+ {` ${helpDots((block as { keyLabel: string }).keyLabel)}`}
1255
+ </span>
1256
+ <span style={{ fg: T.textDim }}>
1257
+ {` ${(block as { desc: string }).desc}`}
1258
+ </span>
1259
+ </text>
1260
+ </Show>
1261
+ </box>
1262
+ )}
1263
+ </For>
1264
+ </scrollbox>
1070
1265
  </DialogShell>
1071
1266
  );
1072
1267
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Width-aware text layout for AgentRow. Pure (no JSX) so it's unit-testable.
3
+ *
4
+ * OpenTUI's own `truncate` elides in the MIDDLE of a text run, which mangles a
5
+ * row made of several fields ("Review the harn...badge-23 gpt-5.5…"). We fit
6
+ * the fields ourselves, dropping the least important ones first and always
7
+ * cutting at the tail.
8
+ */
9
+
10
+ import { cellWidth } from "~/ui/glyphs";
11
+
12
+ /** "▶ " + "● " + "cc " — cursor, status glyph, harness badge. */
13
+ export const LEAD_CELLS = 7;
14
+ /** Row box paddingLeft + paddingRight. */
15
+ const ROW_PADDING = 2;
16
+ /** Cells kept free for width-measurement drift (same margin as zoomed TaskRow). */
17
+ const DRIFT_MARGIN = 2;
18
+ const GAP = " ";
19
+
20
+ /** Cut `s` to at most `max` cells, ending in "…" when cut. */
21
+ export function fitCells(s: string, max: number): string {
22
+ if (max <= 0) return "";
23
+ if (cellWidth(s) <= max) return s;
24
+ let out = "";
25
+ let w = 0;
26
+ for (const ch of s) {
27
+ const cw = cellWidth(ch);
28
+ if (w + cw > max - 1) break;
29
+ out += ch;
30
+ w += cw;
31
+ }
32
+ return out + "…";
33
+ }
34
+
35
+ export interface LineFields {
36
+ name: string;
37
+ branch?: string;
38
+ model?: string;
39
+ /** Right-pinned, e.g. "…Repos/Personal/tuiboard". */
40
+ cwd: string;
41
+ /** Right-pinned, already padded, e.g. " 3m". */
42
+ age: string;
43
+ }
44
+
45
+ export interface LineLayout {
46
+ name: string;
47
+ branch?: string;
48
+ model?: string;
49
+ /** Right-pinned text, empty cwd when there's no room for it. */
50
+ right: string;
51
+ }
52
+
53
+ /** Below this many cells for the name, the cwd gives way first. */
54
+ const MIN_NAME_CELLS = 16;
55
+
56
+ /**
57
+ * Fit a one-line row into `width` cells (the row box's full width).
58
+ * Drop order when space runs out: model, then branch (a short model still
59
+ * shows if only a long branch doesn't fit), then cwd; the name is shortened
60
+ * last. Unknown width (not measured yet) → everything, uncut.
61
+ */
62
+ export function layoutLine(f: LineFields, width: number | undefined): LineLayout {
63
+ if (!width) return { name: f.name, branch: f.branch, model: f.model, right: `${f.cwd}${GAP}${f.age}` };
64
+ const inner = width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS;
65
+
66
+ let right = `${f.cwd}${GAP}${f.age}`;
67
+ if (inner - cellWidth(right) - cellWidth(GAP) < MIN_NAME_CELLS) right = f.age;
68
+ const budget = inner - cellWidth(right) - cellWidth(GAP);
69
+
70
+ const extras = (b?: string, m?: string) =>
71
+ (b ? cellWidth(GAP + b) : 0) + (m ? cellWidth(GAP + m) : 0);
72
+ const nameW = cellWidth(f.name);
73
+ for (const [b, m] of [
74
+ [f.branch, f.model],
75
+ [f.branch, undefined],
76
+ [undefined, f.model],
77
+ [undefined, undefined],
78
+ ] as const) {
79
+ if (nameW + extras(b, m) <= budget) return { name: f.name, branch: b, model: m, right };
80
+ }
81
+ return { name: fitCells(f.name, budget), right };
82
+ }
83
+
84
+ /** Fit the card's detail line (`model · branch · cwd`) with a tail cut. */
85
+ export function layoutCardDetails(parts: (string | undefined)[], width: number | undefined): string {
86
+ const text = parts.filter((p): p is string => Boolean(p)).join(" · ");
87
+ if (!width) return text;
88
+ return fitCells(text, width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS);
89
+ }
90
+
91
+ /** Fit the card's title (the age takes GAP + 3 cells on the right). */
92
+ export function layoutCardName(name: string, width: number | undefined): string {
93
+ if (!width) return name;
94
+ return fitCells(name, width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS - cellWidth(GAP) - 3);
95
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Fullscreen list of every local Claude Code session.
2
+ * Fullscreen list of every local agent session (Claude Code, Codex, OpenCode, Pi).
3
3
  * `tuiboard --view=agents`. Shows ALL sessions (including archived),
4
- * scrollable, cursor-navigable. The scrollbox follows the cursor via
4
+ * scrollable, cursor-navigable, as two-line cards (see AgentRow). The scrollbox follows the cursor via
5
5
  * scrollChildIntoView (same trick used in BoardView for active columns).
6
6
  */
7
7
 
@@ -9,6 +9,7 @@ import { For, Show, createEffect, createMemo } from "solid-js";
9
9
 
10
10
  import { AgentRow } from "~/ui/AgentRow";
11
11
  import { T } from "~/ui/glyphs";
12
+ import { HARNESS } from "~/store/agents";
12
13
  import type { TuiStore } from "~/store/index";
13
14
 
14
15
  interface ScrollBoxLike {
@@ -24,7 +25,9 @@ function rowId(sessionId: string): string {
24
25
  export function AgentsOnly(props: { store: TuiStore }) {
25
26
  const isActive = () => props.store.state.ui.activeZone === "agents";
26
27
  const agentRow = () => props.store.state.ui.row;
27
- const sessions = createMemo(() => props.store.agents.sessions());
28
+ const sessions = createMemo(() => props.store.agentSessions());
29
+ const filter = () => props.store.state.ui.agentsFilter;
30
+ const filterTag = () => (filter() === "all" ? "" : ` · ${HARNESS[filter() as keyof typeof HARNESS].code}`);
28
31
  let scrollBoxRef: ScrollBoxLike | undefined;
29
32
 
30
33
  // Auto-scroll the list so the active row is visible. setTimeout(0) waits
@@ -55,7 +58,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
55
58
  paddingLeft: 1,
56
59
  paddingRight: 1,
57
60
  }}
58
- title={`┤ Agents · ${sessions().length} sessions ├`}
61
+ title={`┤ Agents${filterTag()} · ${sessions().length} sessions ├`}
59
62
  titleAlignment="left"
60
63
  >
61
64
  <Show
@@ -63,7 +66,9 @@ export function AgentsOnly(props: { store: TuiStore }) {
63
66
  fallback={
64
67
  <text>
65
68
  <span style={{ fg: T.textDim }}>
66
- No sessions found in ~/.claude/projects.
69
+ {filter() === "all"
70
+ ? "No agent sessions found (Claude Code, Codex, OpenCode, Pi)."
71
+ : `No ${HARNESS[filter() as keyof typeof HARNESS].name} sessions — press f to change the filter.`}
67
72
  </span>
68
73
  </text>
69
74
  }
@@ -87,6 +92,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
87
92
  session={session}
88
93
  cursor={isActive() && i() === agentRow()}
89
94
  nameMaxChars={120}
95
+ variant="card"
90
96
  onClick={() => {
91
97
  props.store.setActiveZone("agents");
92
98
  props.store.setCursor(0, i());