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.
@@ -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
@@ -95,6 +100,7 @@ export function AgentsBar(props: AgentsBarProps) {
95
100
  {(session, i) => (
96
101
  <box id={agentRowId(i())}>
97
102
  <AgentRow
103
+ indicators={props.store.agentIndicators}
98
104
  session={session}
99
105
  cursor={isActive() && i() === agentRow()}
100
106
  nameMaxChars={48}
package/src/ui/Modal.tsx CHANGED
@@ -22,6 +22,9 @@ import {
22
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";
27
+ import { herdrPlace } from "~/store/herdr";
25
28
  import type { TuiStore } from "~/store/index";
26
29
  import type { PriorityLevel, TimeBlock } from "~/types";
27
30
 
@@ -919,10 +922,42 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
919
922
  <span style={{ fg: T.textDim }}>session </span>
920
923
  <span style={{ fg: T.accent }}>{s().sessionId}</span>
921
924
  </text>
925
+ <text>
926
+ <span style={{ fg: T.textDim }}>harness </span>
927
+ <span style={{ fg: HARNESS_COLOR[s().provider] }}>{HARNESS[s().provider].code}</span>
928
+ <span style={{ fg: T.text }}>{" "}{HARNESS[s().provider].name}</span>
929
+ </text>
930
+ <Show when={s().model}>
931
+ <text>
932
+ <span style={{ fg: T.textDim }}>model </span>
933
+ <span style={{ fg: T.text }}>{s().model}</span>
934
+ </text>
935
+ </Show>
922
936
  <text>
923
937
  <span style={{ fg: T.textDim }}>status </span>
924
938
  <span style={{ fg: T.text }}>{s().status}</span>
925
939
  </text>
940
+ <Show when={s().herdr}>
941
+ {(h: () => NonNullable<ReturnType<typeof s>["herdr"]>) => (
942
+ <>
943
+ <text>
944
+ <span style={{ fg: T.textDim }}>herdr </span>
945
+ <span style={{ fg: T.accent }}>{herdrPlace(h())}</span>
946
+ <span style={{ fg: T.textDim }}>
947
+ {" "}pane {h().paneId} · {h().status} · matched by {h().matchedBy}
948
+ </span>
949
+ </text>
950
+ <Show when={h().matchedBy === "cwd"}>
951
+ <text wrapMode="word">
952
+ <span style={{ fg: T.textDim }}>
953
+ {" "}herdr doesn't report which {h().agent} session this pane is on — run
954
+ `herdr integration install {h().agent}` for an exact match.
955
+ </span>
956
+ </text>
957
+ </Show>
958
+ </>
959
+ )}
960
+ </Show>
926
961
  <text>
927
962
  <span style={{ fg: T.textDim }}>cwd </span>
928
963
  <span style={{ fg: T.text }}>{s().cwd}</span>
@@ -973,17 +1008,37 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
973
1008
  </span>
974
1009
  </text>
975
1010
  </Show>
1011
+ <Show
1012
+ when={
1013
+ props.store.state.ui.lastLaunch?.sessionId === s().sessionId
1014
+ ? props.store.state.ui.lastLaunch
1015
+ : undefined
1016
+ }
1017
+ >
1018
+ {(l: () => NonNullable<TuiStore["state"]["ui"]["lastLaunch"]>) => (
1019
+ <>
1020
+ <box style={{ height: 1 }} />
1021
+ <text>
1022
+ <span style={{ fg: T.textDim }}>last Enter ({new Date(l().ts).toLocaleTimeString()}):</span>
1023
+ </text>
1024
+ <text wrapMode="word">
1025
+ <span style={{ fg: l().ok ? T.done : T.overdue }}>{l().text}</span>
1026
+ </text>
1027
+ </>
1028
+ )}
1029
+ </Show>
976
1030
  <box style={{ height: 1 }} />
977
1031
  <text>
978
1032
  <span style={{ fg: T.textDim }}>
979
- resume — Enter opens this in WezTerm; c copies this command to paste anywhere:
1033
+ resume — Enter opens this in a new terminal tab; c copies this command to paste anywhere:
980
1034
  </span>
981
1035
  </text>
982
1036
  <text wrapMode="word">
983
1037
  <span style={{ fg: T.scheduled }}>
984
1038
  {props.store.config.copyResumeCommand
985
1039
  .replaceAll("{cwd}", s().cwd)
986
- .replaceAll("{sessionId}", s().sessionId)}
1040
+ .replaceAll("{sessionId}", s().sessionId)
1041
+ .replaceAll("{resume}", s().resumeCommand)}
987
1042
  </span>
988
1043
  </text>
989
1044
  </box>
@@ -1085,7 +1140,7 @@ const HELP_SECTIONS: HelpSection[] = [
1085
1140
  rows: [
1086
1141
  ["n", "New task in current column (quick-add syntax)"],
1087
1142
  ["g", "Grab task — h/l then moves it between columns; g/Esc to drop"],
1088
- ["f", "Cycle board filter: all → today → overdue → tomorrow → followup"],
1143
+ ["f", "Cycle board filter: all → today → overdue → tomorrow → followup (Agents zone: harness filter)"],
1089
1144
  ["/", "Search task titles — jumps cursor to first match"],
1090
1145
  ],
1091
1146
  },
@@ -1093,9 +1148,11 @@ const HELP_SECTIONS: HelpSection[] = [
1093
1148
  emoji: "🤖",
1094
1149
  title: "Agents zone",
1095
1150
  rows: [
1096
- ["Enter", "Open (resume) the selected session in a new WezTerm tab"],
1097
- ["c", "Copy a 'cd + claude --resume' command for the session"],
1098
- ["o", "Session detail (cwd, branch, last prompts, resume cmd)"],
1151
+ ["Enter", "Open (resume) the selected session in a new terminal tab/window — or go to it if it's open in herdr"],
1152
+ ["H", "herdr: go to the session, or resume it in a new tab of its project's workspace"],
1153
+ ["c", "Copy a 'cd + resume' command for the session"],
1154
+ ["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
1155
+ ["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
1099
1156
  ],
1100
1157
  },
1101
1158
  {
@@ -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,8 @@ export function AgentsOnly(props: { store: TuiStore }) {
87
92
  session={session}
88
93
  cursor={isActive() && i() === agentRow()}
89
94
  nameMaxChars={120}
95
+ variant="card"
96
+ indicators={props.store.agentIndicators}
90
97
  onClick={() => {
91
98
  props.store.setActiveZone("agents");
92
99
  props.store.setCursor(0, i());