@towles/tool 0.0.123 → 0.0.125

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 (22) hide show
  1. package/package.json +1 -1
  2. package/packages/agentboard/README.md +1 -1
  3. package/packages/agentboard/apps/tui/src/components/SessionCard.tsx +62 -85
  4. package/packages/agentboard/apps/tui/src/components/StatusBar.tsx +0 -35
  5. package/packages/agentboard/apps/tui/src/components/elapsed.test.ts +30 -0
  6. package/packages/agentboard/apps/tui/src/components/elapsed.ts +9 -0
  7. package/packages/agentboard/apps/tui/src/components/family-color.test.ts +70 -0
  8. package/packages/agentboard/apps/tui/src/components/family-color.ts +32 -0
  9. package/packages/agentboard/apps/tui/src/components/short-model.ts +4 -0
  10. package/packages/agentboard/apps/tui/src/components/status-visuals.ts +17 -0
  11. package/packages/agentboard/apps/tui/src/index.tsx +42 -52
  12. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +77 -1
  13. package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +35 -3
  14. package/packages/agentboard/packages/runtime/src/contracts/agent.ts +2 -0
  15. package/packages/agentboard/packages/runtime/src/index.ts +2 -0
  16. package/packages/agentboard/packages/runtime/src/server/metadata-store.ts +2 -5
  17. package/packages/agentboard/packages/runtime/src/server/session-order.ts +6 -3
  18. package/packages/agentboard/packages/runtime/src/shared.ts +4 -1
  19. package/packages/agentboard/packages/runtime/src/text-utils.ts +4 -0
  20. package/packages/agentboard/apps/tui/src/components/cache-bar.ts +0 -33
  21. package/packages/agentboard/apps/tui/src/session-status.test.ts +0 -70
  22. package/packages/agentboard/apps/tui/src/session-status.ts +0 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.123",
3
+ "version": "0.0.125",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -114,7 +114,7 @@ Solid.js app rendered via OpenTUI. Connects to server over WebSocket.
114
114
 
115
115
  - `constants.ts` — shared icons (`SPINNERS`, `UNSEEN_ICON`), theme list, tone-to-color mapping
116
116
  - `mux-context.ts` — tmux detection, pane refocus after startup, client TTY and session name resolution
117
- - `components/cache-bar.ts` — cache-countdown bar helpers (`cacheBar`, `cacheBarColor`, `shortModel`)
117
+ - `components/short-model.ts` — `shortModel` helper for displaying agent model names
118
118
 
119
119
  ## Configuration
120
120
 
@@ -1,9 +1,13 @@
1
- import { createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js";
1
+ import { createSignal, For, Show, onCleanup } from "solid-js";
2
2
  import type { Accessor } from "solid-js";
3
3
  import type { AgentStatus, SessionData, Theme } from "@tt-agentboard/runtime";
4
- import { SPINNERS, UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
4
+ import { truncate } from "@tt-agentboard/runtime";
5
+ import { UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
5
6
  import { DiffStats } from "./DiffStats";
6
- import { cacheBar, cacheBarColor, shortModel } from "./cache-bar";
7
+ import { shortModel } from "./short-model";
8
+ import { formatElapsed } from "./elapsed";
9
+ import { liveStatusIcon, unseenTerminalColor } from "./status-visuals";
10
+ import { familyColor } from "./family-color";
7
11
 
8
12
  const STATUS_TEXT: Record<AgentStatus, string> = {
9
13
  idle: "",
@@ -17,10 +21,10 @@ const STATUS_TEXT: Record<AgentStatus, string> = {
17
21
 
18
22
  export interface SessionCardProps {
19
23
  session: SessionData;
20
- index: number;
21
24
  isFocused: boolean;
22
25
  isCurrent: boolean;
23
26
  spinIdx: Accessor<number>;
27
+ now: Accessor<number>;
24
28
  theme: Accessor<Theme>;
25
29
  statusColors: Accessor<Theme["status"]>;
26
30
  focusedAgentIdx: number;
@@ -42,7 +46,7 @@ export function SessionCard(props: SessionCardProps) {
42
46
 
43
47
  const accentColor = () => {
44
48
  if (props.isCurrent) return P().green;
45
- if (isUnseenTerminal()) return unseenAccentColor();
49
+ if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
46
50
  const s = status();
47
51
  if (s === "error") return P().red;
48
52
  if (s === "interrupted") return P().peach;
@@ -53,48 +57,27 @@ export function SessionCard(props: SessionCardProps) {
53
57
  return "transparent";
54
58
  };
55
59
 
56
- const unseenAccentColor = () => {
57
- const s = status();
58
- if (s === "error") return P().red;
59
- if (s === "interrupted") return P().peach;
60
- return P().teal;
61
- };
62
-
63
60
  const statusIcon = () => {
64
- const s = status();
65
- if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
66
- if (s === "waiting") return "";
67
- if (s === "question") return "?";
68
- if (isUnseenTerminal()) return UNSEEN_ICON;
69
- return "";
61
+ const live = liveStatusIcon(status(), props.spinIdx());
62
+ if (live) return live;
63
+ return isUnseenTerminal() ? UNSEEN_ICON : "";
70
64
  };
71
65
 
72
66
  const statusColor = () => {
73
- if (isUnseenTerminal()) return unseenAccentColor();
67
+ if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
74
68
  return SC()[status()];
75
69
  };
76
70
 
71
+ const familyHue = () => familyColor(props.session.name, P());
72
+
77
73
  const nameColor = () => {
78
74
  if (props.isFocused) return P().text;
79
75
  if (props.isCurrent) return P().subtext1;
80
- return P().subtext0;
81
- };
82
-
83
- const indexColor = () => {
84
- if (props.isFocused) return P().subtext0;
85
- return P().surface2;
76
+ return familyHue();
86
77
  };
87
78
 
88
- const truncName = () => {
89
- const n = props.session.name;
90
- return n.length > 18 ? n.slice(0, 17) + "…" : n;
91
- };
92
-
93
- const truncBranch = () => {
94
- const b = props.session.branch;
95
- if (!b) return "";
96
- return b.length > 30 ? b.slice(0, 29) + "…" : b;
97
- };
79
+ const truncName = () => truncate(props.session.name, 18);
80
+ const truncBranch = () => (props.session.branch ? truncate(props.session.branch, 30) : "");
98
81
 
99
82
  const hasDiff = () => {
100
83
  const { linesAdded, linesRemoved, commitsDelta, filesChanged } = props.session;
@@ -120,20 +103,12 @@ export function SessionCard(props: SessionCardProps) {
120
103
  const metaTone = () => props.session.metadata?.status?.tone;
121
104
 
122
105
  const bgColor = () => {
123
- if (props.isFocused) return P().surface1;
106
+ if (props.isFocused) return P().surface0;
124
107
  return "transparent";
125
108
  };
126
109
 
127
110
  const agents = () => props.session.agents ?? [];
128
111
 
129
- const [now, setNow] = createSignal(Date.now());
130
- const needsTicker = createMemo(() => agents().some((a) => a.details?.cacheExpiresAt != null));
131
- createEffect(() => {
132
- if (!needsTicker()) return;
133
- const id = setInterval(() => setNow(Date.now()), 1000);
134
- onCleanup(() => clearInterval(id));
135
- });
136
-
137
112
  return (
138
113
  <box flexDirection="column" flexShrink={0}>
139
114
  <box
@@ -144,9 +119,13 @@ export function SessionCard(props: SessionCardProps) {
144
119
  >
145
120
  <text style={{ fg: accentColor() }}>{accentColor() === "transparent" ? " " : "▌"}</text>
146
121
 
147
- <box width={3} flexShrink={0}>
148
- <text style={{ fg: indexColor() }}>{String(props.index).padStart(2)}</text>
149
- </box>
122
+ <Show when={accentColor() === "transparent"}>
123
+ <box width={1} flexShrink={0}>
124
+ <text>
125
+ <span style={{ fg: familyHue(), attributes: DIM }}>▎</span>
126
+ </text>
127
+ </box>
128
+ </Show>
150
129
 
151
130
  <box flexDirection="column" flexGrow={1} paddingRight={1}>
152
131
  <box flexDirection="row">
@@ -196,7 +175,7 @@ export function SessionCard(props: SessionCardProps) {
196
175
  palette={P}
197
176
  statusColors={props.statusColors}
198
177
  spinIdx={props.spinIdx}
199
- now={now}
178
+ now={props.now}
200
179
  isKeyboardFocused={i() === props.focusedAgentIdx}
201
180
  onDismiss={() => props.onDismissAgent(agent)}
202
181
  onFocusPane={() => props.onFocusAgentPane(agent)}
@@ -233,19 +212,20 @@ function AgentRow(props: AgentRowProps) {
233
212
 
234
213
  const icon = () => {
235
214
  if (isUnseen()) return UNSEEN_ICON;
236
- if (isTerminal())
237
- return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
238
- if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
239
- if (props.agent.status === "waiting") return "";
240
- if (props.agent.status === "question") return "?";
241
- return "○";
215
+ if (isTerminal()) {
216
+ if (props.agent.status === "done") return "✓";
217
+ if (props.agent.status === "error") return "✗";
218
+ return "";
219
+ }
220
+ return liveStatusIcon(props.agent.status, props.spinIdx()) || "○";
242
221
  };
243
222
 
244
223
  const color = () => {
245
224
  if (isTerminal()) {
225
+ if (isUnseen()) return unseenTerminalColor(props.agent.status, P());
246
226
  if (props.agent.status === "error") return P().red;
247
227
  if (props.agent.status === "interrupted") return P().peach;
248
- return isUnseen() ? P().teal : P().green;
228
+ return P().green;
249
229
  }
250
230
  return SC()[props.agent.status];
251
231
  };
@@ -263,8 +243,8 @@ function AgentRow(props: AgentRowProps) {
263
243
  });
264
244
 
265
245
  const bgColor = () => {
266
- if (isFlash()) return P().surface1;
267
- if (props.isKeyboardFocused) return P().surface0;
246
+ if (isFlash()) return P().surface2;
247
+ if (props.isKeyboardFocused) return P().surface1;
268
248
  return "transparent";
269
249
  };
270
250
 
@@ -282,15 +262,17 @@ function AgentRow(props: AgentRowProps) {
282
262
  <box flexDirection="row">
283
263
  <text flexGrow={1} truncate>
284
264
  <span style={{ fg: color() }}>{icon()}</span>
285
- <span
286
- style={{
287
- fg: props.isKeyboardFocused ? P().text : P().subtext1,
288
- attributes: props.isKeyboardFocused ? BOLD : undefined,
289
- }}
290
- >
291
- {" "}
292
- {props.agent.agent}
293
- </span>
265
+ <Show when={props.agent.status === "running" && props.agent.details?.lastActivityAt}>
266
+ <span
267
+ style={{
268
+ fg: props.isKeyboardFocused ? P().subtext0 : P().overlay1,
269
+ attributes: DIM,
270
+ }}
271
+ >
272
+ {" "}
273
+ {formatElapsed(props.now() - (props.agent.details?.lastActivityAt ?? props.now()))}
274
+ </span>
275
+ </Show>
294
276
  </text>
295
277
  <Show when={!isUnseen()}>
296
278
  <text flexShrink={0}>
@@ -312,35 +294,30 @@ function AgentRow(props: AgentRowProps) {
312
294
  </box>
313
295
 
314
296
  <Show when={props.agent.threadName}>
315
- <text truncate>
316
- <span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
317
- {props.agent.threadName!.replace(/\s+/g, " ").trim()}
318
- </span>
319
- </text>
297
+ <box height={2} flexShrink={0}>
298
+ <text>
299
+ <span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
300
+ {truncate(props.agent.threadName!.replace(/\s+/g, " ").trim(), 60)}
301
+ </span>
302
+ </text>
303
+ </box>
320
304
  </Show>
321
305
 
322
- <Show when={props.agent.details}>
306
+ <Show when={props.agent.status === "running" && props.agent.details}>
323
307
  {(d) => {
324
308
  const details = d();
325
309
  const model = () => (details.model ? shortModel(details.model) : "");
326
- const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
327
- const bar = () =>
328
- hasCache() ? cacheBar(details.cacheExpiresAt!, details.cacheTtlMs!, props.now()) : "";
329
- const barColor = () =>
330
- hasCache()
331
- ? cacheBarColor(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
332
- : P().overlay0;
310
+ const tool = () => details.lastTool;
333
311
  return (
334
- <Show when={model() || hasCache()}>
312
+ <Show when={model() || tool()}>
335
313
  <text truncate>
336
314
  <Show when={model()}>
337
315
  <span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
338
316
  </Show>
339
- <Show when={hasCache()}>
340
- <span style={{ fg: P().overlay0, attributes: DIM }}>
341
- {model() ? " · cache " : "cache "}
342
- </span>
343
- <span style={{ fg: barColor() }}>{bar()}</span>
317
+ <Show when={tool()}>
318
+ <span style={{ fg: P().overlay0, attributes: DIM }}>{model() ? " · " : ""}</span>
319
+ <span style={{ fg: P().teal, attributes: DIM }}>⟶ </span>
320
+ <span style={{ fg: P().subtext0 }}>{tool()}</span>
344
321
  </Show>
345
322
  </text>
346
323
  </Show>
@@ -1,21 +1,13 @@
1
1
  import { Show } from "solid-js";
2
2
  import type { Accessor } from "solid-js";
3
3
  import type { Theme } from "@tt-agentboard/runtime";
4
- import { STATUS_ICONS } from "@tt-agentboard/runtime";
5
4
  import { BOLD } from "../constants";
6
5
 
7
- export interface SessionStatusCounts {
8
- active: number;
9
- error: number;
10
- idle: number;
11
- }
12
-
13
6
  export interface StatusBarProps {
14
7
  sessionCount: number;
15
8
  runningCount: number;
16
9
  errorCount: number;
17
10
  unseenCount: number;
18
- sessionStatusCounts: SessionStatusCounts;
19
11
  theme: Accessor<Theme>;
20
12
  }
21
13
 
@@ -52,33 +44,6 @@ export function StatusBar(props: StatusBarProps) {
52
44
  </span>
53
45
  </Show>
54
46
  </text>
55
- <Show
56
- when={
57
- props.sessionStatusCounts.active +
58
- props.sessionStatusCounts.error +
59
- props.sessionStatusCounts.idle >
60
- 0
61
- }
62
- >
63
- <text>
64
- <span style={{ fg: P().overlay1 }}>{" "}</span>
65
- <Show when={props.sessionStatusCounts.active > 0}>
66
- <span style={{ fg: P().green }}>
67
- {STATUS_ICONS.running} {props.sessionStatusCounts.active} active{" "}
68
- </span>
69
- </Show>
70
- <Show when={props.sessionStatusCounts.error > 0}>
71
- <span style={{ fg: P().red }}>
72
- {STATUS_ICONS.error} {props.sessionStatusCounts.error} error{" "}
73
- </span>
74
- </Show>
75
- <Show when={props.sessionStatusCounts.idle > 0}>
76
- <span style={{ fg: P().surface2 }}>
77
- {STATUS_ICONS.idle} {props.sessionStatusCounts.idle} idle
78
- </span>
79
- </Show>
80
- </text>
81
- </Show>
82
47
  </box>
83
48
  );
84
49
  }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatElapsed } from "./elapsed";
3
+
4
+ describe("formatElapsed", () => {
5
+ it("formats sub-minute durations in seconds", () => {
6
+ expect(formatElapsed(0)).toBe("0s");
7
+ expect(formatElapsed(5_000)).toBe("5s");
8
+ expect(formatElapsed(59_000)).toBe("59s");
9
+ });
10
+
11
+ it("formats sub-hour durations in whole minutes", () => {
12
+ expect(formatElapsed(60_000)).toBe("1m");
13
+ expect(formatElapsed(3 * 60_000)).toBe("3m");
14
+ expect(formatElapsed(59 * 60_000)).toBe("59m");
15
+ });
16
+
17
+ it("formats hour+ durations in whole hours", () => {
18
+ expect(formatElapsed(60 * 60_000)).toBe("1h");
19
+ expect(formatElapsed(5 * 60 * 60_000)).toBe("5h");
20
+ });
21
+
22
+ it("clamps negative values to 0s", () => {
23
+ expect(formatElapsed(-1000)).toBe("0s");
24
+ });
25
+
26
+ it("floors rather than rounds", () => {
27
+ expect(formatElapsed(59_999)).toBe("59s");
28
+ expect(formatElapsed(119_999)).toBe("1m");
29
+ });
30
+ });
@@ -0,0 +1,9 @@
1
+ export function formatElapsed(ms: number): string {
2
+ if (ms < 0) ms = 0;
3
+ const seconds = Math.floor(ms / 1000);
4
+ if (seconds < 60) return `${seconds}s`;
5
+ const minutes = Math.floor(seconds / 60);
6
+ if (minutes < 60) return `${minutes}m`;
7
+ const hours = Math.floor(minutes / 60);
8
+ return `${hours}h`;
9
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { familyOf, familyColor } from "./family-color";
3
+ import type { Theme } from "@tt-agentboard/runtime";
4
+
5
+ const palette = {
6
+ pink: "#f5c2e7",
7
+ peach: "#fab387",
8
+ teal: "#94e2d5",
9
+ sky: "#89dceb",
10
+ lavender: "#b4befe",
11
+ mauve: "#cba6f7",
12
+ blue: "#89b4fa",
13
+ green: "#a6e3a1",
14
+ yellow: "#f9e2af",
15
+ red: "#f38ba8",
16
+ subtext0: "#a6adc8",
17
+ } as unknown as Theme["palette"];
18
+
19
+ describe("familyOf", () => {
20
+ it("groups blog-* sessions", () => {
21
+ expect(familyOf("blog-primary")).toBe("blog");
22
+ expect(familyOf("blog-slot-1")).toBe("blog");
23
+ expect(familyOf("blog-slot-2")).toBe("blog");
24
+ });
25
+
26
+ it("groups towles-tool-* sessions", () => {
27
+ expect(familyOf("towles-tool-primary")).toBe("towles-tool");
28
+ expect(familyOf("towles-tool-slot-1")).toBe("towles-tool");
29
+ });
30
+
31
+ it("returns the full name for solo sessions", () => {
32
+ expect(familyOf("dotfiles")).toBe("dotfiles");
33
+ expect(familyOf("f")).toBe("f");
34
+ expect(familyOf("toolbox")).toBe("toolbox");
35
+ });
36
+
37
+ it("handles single-segment names without dash", () => {
38
+ expect(familyOf("foo")).toBe("foo");
39
+ });
40
+
41
+ it("treats -primary and -slot-N as slot suffixes only", () => {
42
+ expect(familyOf("my-project-primary")).toBe("my-project");
43
+ expect(familyOf("my-project-slot-9")).toBe("my-project");
44
+ expect(familyOf("my-project-other")).toBe("my-project-other");
45
+ });
46
+ });
47
+
48
+ describe("familyColor", () => {
49
+ it("maps known families to specific palette colors", () => {
50
+ expect(familyColor("blog-primary", palette)).toBe(palette.pink);
51
+ expect(familyColor("dotfiles", palette)).toBe(palette.peach);
52
+ expect(familyColor("f", palette)).toBe(palette.teal);
53
+ expect(familyColor("toolbox", palette)).toBe(palette.sky);
54
+ expect(familyColor("towles-tool-primary", palette)).toBe(palette.lavender);
55
+ });
56
+
57
+ it("gives the same color to sessions in the same family", () => {
58
+ expect(familyColor("blog-primary", palette)).toBe(familyColor("blog-slot-2", palette));
59
+ expect(familyColor("towles-tool-primary", palette)).toBe(
60
+ familyColor("towles-tool-slot-1", palette),
61
+ );
62
+ });
63
+
64
+ it("falls back to a deterministic palette hue for unknown families", () => {
65
+ const a = familyColor("unknown-repo", palette);
66
+ const b = familyColor("unknown-repo", palette);
67
+ expect(a).toBe(b); // deterministic
68
+ expect(a).not.toBe(palette.subtext0); // not the legacy grey
69
+ });
70
+ });
@@ -0,0 +1,32 @@
1
+ import type { Theme } from "@tt-agentboard/runtime";
2
+
3
+ const KNOWN_FAMILIES = new Map<string, keyof Theme["palette"]>([
4
+ ["blog", "pink"],
5
+ ["dotfiles", "peach"],
6
+ ["f", "teal"],
7
+ ["toolbox", "sky"],
8
+ ["towles-tool", "lavender"],
9
+ ]);
10
+
11
+ const FALLBACK_HUES: Array<keyof Theme["palette"]> = ["mauve", "blue", "green", "yellow", "red"];
12
+
13
+ const SLOT_SUFFIX = /-(?:primary|slot-\d+)$/;
14
+
15
+ export function familyOf(sessionName: string): string {
16
+ const stripped = sessionName.replace(SLOT_SUFFIX, "");
17
+ return stripped || sessionName;
18
+ }
19
+
20
+ function hash(s: string): number {
21
+ let h = 0;
22
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
23
+ return Math.abs(h);
24
+ }
25
+
26
+ export function familyColor(sessionName: string, palette: Theme["palette"]): string {
27
+ const family = familyOf(sessionName);
28
+ const known = KNOWN_FAMILIES.get(family);
29
+ if (known) return palette[known] as string;
30
+ const key = FALLBACK_HUES[hash(family) % FALLBACK_HUES.length]!;
31
+ return palette[key] as string;
32
+ }
@@ -0,0 +1,4 @@
1
+ export function shortModel(model: string): string {
2
+ if (!model) return "";
3
+ return model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
4
+ }
@@ -0,0 +1,17 @@
1
+ import type { AgentStatus, Theme } from "@tt-agentboard/runtime";
2
+ import { SPINNERS } from "../constants";
3
+
4
+ /** Icon for a live (non-terminal) status. Returns "" for statuses without a glyph. */
5
+ export function liveStatusIcon(status: AgentStatus, spinIdx: number): string {
6
+ if (status === "running") return SPINNERS[spinIdx % SPINNERS.length]!;
7
+ if (status === "waiting") return "◉";
8
+ if (status === "question") return "?";
9
+ return "";
10
+ }
11
+
12
+ /** Accent color for a terminal agent whose final status the user hasn't seen yet. */
13
+ export function unseenTerminalColor(status: AgentStatus, palette: Theme["palette"]): string {
14
+ if (status === "error") return palette.red;
15
+ if (status === "interrupted") return palette.peach;
16
+ return palette.teal;
17
+ }
@@ -15,10 +15,15 @@ import type { Accessor } from "solid-js";
15
15
  import { createStore, reconcile } from "solid-js/store";
16
16
 
17
17
  import { ensureServer, SERVER_PORT, SERVER_HOST, resolveTheme } from "@tt-agentboard/runtime";
18
- import type { ServerMessage, SessionData, ClientCommand, Theme } from "@tt-agentboard/runtime";
18
+ import type {
19
+ ServerMessage,
20
+ SessionData,
21
+ ClientCommand,
22
+ ReorderDelta,
23
+ Theme,
24
+ } from "@tt-agentboard/runtime";
19
25
  import { SessionCard } from "./components/SessionCard";
20
26
  import { StatusBar } from "./components/StatusBar";
21
- import { computeSessionStatusCounts } from "./session-status";
22
27
  import {
23
28
  detectMuxContext,
24
29
  refocusMainPane,
@@ -369,6 +374,18 @@ function App() {
369
374
  onCleanup(() => clearInterval(interval));
370
375
  });
371
376
 
377
+ // Shared 1s clock for elapsed-time displays.
378
+ // Ticks only while any agent is running.
379
+ const [now, setNow] = createSignal(Date.now());
380
+ const needsTicker = createMemo(() =>
381
+ sessions.some((s) => s.agents?.some((a) => a.status === "running")),
382
+ );
383
+ createEffect(() => {
384
+ if (!needsTicker()) return;
385
+ const id = setInterval(() => setNow(Date.now()), 1000);
386
+ onCleanup(() => clearInterval(id));
387
+ });
388
+
372
389
  useKeyboard((key) => {
373
390
  const currentModal = modal();
374
391
 
@@ -397,13 +414,8 @@ function App() {
397
414
  if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
398
415
  const focused = focusedSession();
399
416
  if (focused) {
400
- const delta: -1 | 1 | "top" | "bottom" = key.shift
401
- ? key.name === "up"
402
- ? "top"
403
- : "bottom"
404
- : key.name === "up"
405
- ? -1
406
- : 1;
417
+ const up = key.name === "up";
418
+ const delta: ReorderDelta = key.shift ? (up ? "top" : "bottom") : up ? "up" : "down";
407
419
  send({ type: "reorder-session", name: focused, delta });
408
420
  }
409
421
  return;
@@ -515,7 +527,6 @@ function App() {
515
527
  );
516
528
 
517
529
  const unseenCount = createMemo(() => sessions.filter((s) => s.unseen).length);
518
- const sessionStatusCounts = createMemo(() => computeSessionStatusCounts(sessions));
519
530
 
520
531
  const isFocused = createSelector(focusedSession);
521
532
 
@@ -527,7 +538,6 @@ function App() {
527
538
  runningCount={runningAgentCount()}
528
539
  errorCount={errorAgentCount()}
529
540
  unseenCount={unseenCount()}
530
- sessionStatusCounts={sessionStatusCounts()}
531
541
  theme={theme}
532
542
  />
533
543
 
@@ -537,10 +547,10 @@ function App() {
537
547
  {(session, i) => (
538
548
  <SessionCard
539
549
  session={session}
540
- index={i() + 1}
541
550
  isFocused={isFocused(session.name)}
542
551
  isCurrent={session.name === currentSession()}
543
552
  spinIdx={spinIdx}
553
+ now={now}
544
554
  theme={theme}
545
555
  statusColors={S}
546
556
  focusedAgentIdx={
@@ -606,20 +616,12 @@ function App() {
606
616
  />
607
617
  }
608
618
  >
609
- <KeyHints
610
- palette={P}
611
- hints={[
612
- ["⇥", "cycle"],
613
- ["⏎", "switch"],
614
- ["→", "agents"],
615
- ["n", "new"],
616
- ["e", "edit"],
617
- ["x", "kill"],
618
- ["r", "refresh"],
619
- ["q", "quit"],
620
- ["?", "help"],
621
- ]}
622
- />
619
+ <box height={1}>
620
+ <text>
621
+ <span style={{ fg: P().overlay0 }}>?</span>
622
+ <span style={{ fg: P().overlay1 }}> help</span>
623
+ </text>
624
+ </box>
623
625
  </Show>
624
626
  </box>
625
627
 
@@ -682,16 +684,10 @@ const HELP_KEYS: [string, string][] = [
682
684
  ["→/l", "Agents panel"],
683
685
  ["←/h/Esc", "Back to sessions"],
684
686
  ["Alt+↑↓", "Reorder sessions"],
685
- ["Alt+Shift+↑↓", "Move to top/bottom"],
687
+ ["Alt+Shift+↑↓", "To top/bottom"],
686
688
  ["q", "Quit"],
687
689
  ];
688
690
 
689
- const HELP_COLS = 2;
690
- const HELP_ROWS = Math.ceil(HELP_KEYS.length / HELP_COLS);
691
- const HELP_COLUMNS = Array.from({ length: HELP_COLS }, (_, c) =>
692
- HELP_KEYS.slice(c * HELP_ROWS, (c + 1) * HELP_ROWS),
693
- );
694
-
695
691
  function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () => void }) {
696
692
  const P = () => props.palette();
697
693
  return (
@@ -712,7 +708,7 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
712
708
  backgroundColor={P().mantle}
713
709
  padding={1}
714
710
  flexDirection="column"
715
- width={56}
711
+ width={32}
716
712
  >
717
713
  <text>
718
714
  <span style={{ fg: P().blue, attributes: BOLD }}>Keybindings</span>
@@ -720,24 +716,18 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
720
716
  <box height={1}>
721
717
  <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
722
718
  </box>
723
- <box flexDirection="row">
724
- <For each={HELP_COLUMNS}>
725
- {(col) => (
726
- <box flexDirection="column" flexGrow={1}>
727
- <For each={col}>
728
- {([key, desc]) => (
729
- <box flexDirection="row" paddingLeft={1}>
730
- <box width={12} flexShrink={0}>
731
- <text>
732
- <span style={{ fg: P().sky }}>{key}</span>
733
- </text>
734
- </box>
735
- <text truncate>
736
- <span style={{ fg: P().subtext0 }}>{desc}</span>
737
- </text>
738
- </box>
739
- )}
740
- </For>
719
+ <box flexDirection="column">
720
+ <For each={HELP_KEYS}>
721
+ {([key, desc]) => (
722
+ <box flexDirection="row">
723
+ <box width={14} flexShrink={0}>
724
+ <text truncate>
725
+ <span style={{ fg: P().sky }}>{key}</span>
726
+ </text>
727
+ </box>
728
+ <text truncate>
729
+ <span style={{ fg: P().subtext0 }}>{desc}</span>
730
+ </text>
741
731
  </box>
742
732
  )}
743
733
  </For>
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { determineStatus, summaryToDetails } from "./claude-code";
2
+ import { determineStatus, summaryToDetails, extractLastTool } from "./claude-code";
3
3
  import type { ClaudeUsageSummary } from "./claude-usage";
4
4
 
5
5
  describe("determineStatus", () => {
@@ -98,3 +98,79 @@ describe("summaryToDetails", () => {
98
98
  expect(details.model).toBe("claude-haiku-4-5");
99
99
  });
100
100
  });
101
+
102
+ describe("extractLastTool", () => {
103
+ it("returns undefined when no entries", () => {
104
+ expect(extractLastTool([])).toBeUndefined();
105
+ });
106
+
107
+ it("returns undefined when no assistant tool_use entries", () => {
108
+ expect(
109
+ extractLastTool([
110
+ { message: { role: "assistant", content: [{ type: "text", text: "hello" }] } },
111
+ { message: { role: "user", content: "hi" } },
112
+ ]),
113
+ ).toBeUndefined();
114
+ });
115
+
116
+ it("returns tool name from the most recent assistant tool_use", () => {
117
+ expect(
118
+ extractLastTool([
119
+ { message: { role: "assistant", content: [{ type: "tool_use", name: "Read" }] } },
120
+ ]),
121
+ ).toBe("Read");
122
+ });
123
+
124
+ it("prefers the latest entry when multiple tool_use present", () => {
125
+ expect(
126
+ extractLastTool([
127
+ { message: { role: "assistant", content: [{ type: "tool_use", name: "Read" }] } },
128
+ { message: { role: "user", content: "ok" } },
129
+ { message: { role: "assistant", content: [{ type: "tool_use", name: "Edit" }] } },
130
+ ]),
131
+ ).toBe("Edit");
132
+ });
133
+
134
+ it("skips AskUserQuestion (not a real tool use for display)", () => {
135
+ expect(
136
+ extractLastTool([
137
+ { message: { role: "assistant", content: [{ type: "tool_use", name: "Read" }] } },
138
+ {
139
+ message: {
140
+ role: "assistant",
141
+ content: [{ type: "tool_use", name: "AskUserQuestion" }],
142
+ },
143
+ },
144
+ ]),
145
+ ).toBe("Read");
146
+ });
147
+
148
+ it("returns undefined when only AskUserQuestion tool_use entries exist", () => {
149
+ expect(
150
+ extractLastTool([
151
+ {
152
+ message: {
153
+ role: "assistant",
154
+ content: [{ type: "tool_use", name: "AskUserQuestion" }],
155
+ },
156
+ },
157
+ ]),
158
+ ).toBeUndefined();
159
+ });
160
+
161
+ it("returns the first tool name if a turn has multiple tool_use items", () => {
162
+ expect(
163
+ extractLastTool([
164
+ {
165
+ message: {
166
+ role: "assistant",
167
+ content: [
168
+ { type: "tool_use", name: "Read" },
169
+ { type: "tool_use", name: "Grep" },
170
+ ],
171
+ },
172
+ },
173
+ ]),
174
+ ).toBe("Read");
175
+ });
176
+ });
@@ -46,6 +46,7 @@ interface SessionState {
46
46
  threadName?: string;
47
47
  projectDir?: string;
48
48
  usage?: ClaudeUsageSummary;
49
+ lastTool?: string;
49
50
  }
50
51
 
51
52
  const POLL_MS = 2000;
@@ -95,6 +96,23 @@ function extractThreadName(entry: JournalEntry): string | undefined {
95
96
  return text.slice(0, 80);
96
97
  }
97
98
 
99
+ export function extractLastTool(entries: JournalEntry[]): string | undefined {
100
+ for (let i = entries.length - 1; i >= 0; i--) {
101
+ const entry = entries[i]!;
102
+ const msg = entry.message;
103
+ if (msg?.role !== "assistant") continue;
104
+ const content = msg.content;
105
+ if (!Array.isArray(content)) continue;
106
+ for (const item of content) {
107
+ if (item.type !== "tool_use") continue;
108
+ if (!item.name) continue;
109
+ if (item.name === "AskUserQuestion") continue;
110
+ return item.name;
111
+ }
112
+ }
113
+ return undefined;
114
+ }
115
+
98
116
  /** Decode Claude's encoded project dir name back to a path.
99
117
  * Claude Code encodes `/` as `-` with no escape for literal dashes,
100
118
  * so paths like `/home/user/my-project` are ambiguous with `/home/user/my/project`.
@@ -118,6 +136,15 @@ export function summaryToDetails(
118
136
  };
119
137
  }
120
138
 
139
+ function buildDetails(
140
+ usage: ClaudeUsageSummary | undefined,
141
+ lastTool: string | undefined,
142
+ ): import("../../contracts/agent").AgentEventDetails | undefined {
143
+ if (!usage && !lastTool) return undefined;
144
+ const base = usage ? summaryToDetails(usage) : {};
145
+ return lastTool ? { ...base, lastTool } : base;
146
+ }
147
+
121
148
  // --- Watcher implementation ---
122
149
 
123
150
  export class ClaudeCodeAgentWatcher implements AgentWatcher {
@@ -198,7 +225,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
198
225
  ts: Date.now(),
199
226
  threadId,
200
227
  threadName: prev.threadName,
201
- details: prev.usage ? summaryToDetails(prev.usage) : undefined,
228
+ details: buildDetails(prev.usage, prev.lastTool),
202
229
  });
203
230
  }
204
231
  }
@@ -237,6 +264,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
237
264
  }
238
265
 
239
266
  const usage = extractUsageSummary(parsed) ?? undefined;
267
+ const lastTool = extractLastTool(parsed);
240
268
 
241
269
  // If "running" but journal file is stale, the process likely exited
242
270
  if (latestStatus === "running") {
@@ -252,6 +280,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
252
280
  threadName,
253
281
  projectDir,
254
282
  usage,
283
+ lastTool,
255
284
  });
256
285
  return;
257
286
  }
@@ -291,6 +320,8 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
291
320
  // Merge new usage summary onto the previous one (incremental reads may not include the latest assistant turn)
292
321
  const newUsage = extractUsageSummary(parsed);
293
322
  const usage = newUsage ?? prev?.usage;
323
+ const newLastTool = extractLastTool(parsed);
324
+ const lastTool = newLastTool ?? prev?.lastTool;
294
325
 
295
326
  if (latestStatus === "running") {
296
327
  const pid = await this.pidLookup.pidForThread(threadId);
@@ -306,6 +337,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
306
337
  threadName,
307
338
  projectDir,
308
339
  usage,
340
+ lastTool,
309
341
  });
310
342
 
311
343
  if (latestStatus !== prevStatus) {
@@ -318,7 +350,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
318
350
  ts: Date.now(),
319
351
  threadId,
320
352
  threadName,
321
- details: usage ? summaryToDetails(usage) : undefined,
353
+ details: buildDetails(usage, lastTool),
322
354
  });
323
355
  }
324
356
  }
@@ -396,7 +428,7 @@ export class ClaudeCodeAgentWatcher implements AgentWatcher {
396
428
  ts: Date.now(),
397
429
  threadId,
398
430
  threadName: state.threadName,
399
- details: state.usage ? summaryToDetails(state.usage) : undefined,
431
+ details: buildDetails(state.usage, state.lastTool),
400
432
  });
401
433
  }
402
434
  }
@@ -20,6 +20,8 @@ export interface AgentEventDetails {
20
20
  cacheTtlMs?: number;
21
21
  /** Epoch ms of the most recent assistant entry in the journal */
22
22
  lastActivityAt?: number;
23
+ /** Name of the most recent tool invoked by the agent (e.g. "Read", "Bash", "Edit"). Populated only by the claude-code watcher. */
24
+ lastTool?: string;
23
25
  }
24
26
 
25
27
  export interface AgentEvent {
@@ -61,9 +61,11 @@ export type {
61
61
  QuitNotify,
62
62
  ServerMessage,
63
63
  ClientCommand,
64
+ ReorderDelta,
64
65
  MetadataTone,
65
66
  MetadataStatus,
66
67
  MetadataProgress,
67
68
  MetadataLogEntry,
68
69
  SessionMetadata,
69
70
  } from "./shared";
71
+ export { truncate } from "./text-utils";
@@ -1,12 +1,9 @@
1
1
  import type { MetadataTone, SessionMetadata } from "../shared";
2
+ import { truncate } from "../text-utils";
2
3
 
3
4
  const MAX_LOGS = 50;
4
5
  const MAX_MESSAGE_LENGTH = 500;
5
6
 
6
- function truncate(s: string, max: number = MAX_MESSAGE_LENGTH): string {
7
- return s.length > max ? s.slice(0, max - 1) + "…" : s;
8
- }
9
-
10
7
  export class SessionMetadataStore {
11
8
  private store = new Map<string, SessionMetadata>();
12
9
 
@@ -62,7 +59,7 @@ export class SessionMetadataStore {
62
59
  ): void {
63
60
  const meta = this.getOrCreate(session);
64
61
  meta.logs.push({
65
- message: truncate(entry.message),
62
+ message: truncate(entry.message, MAX_MESSAGE_LENGTH),
66
63
  tone: entry.tone,
67
64
  source: entry.source ? truncate(entry.source, 50) : undefined,
68
65
  ts: Date.now(),
@@ -5,6 +5,8 @@ interface PersistedSessionOrder {
5
5
  order?: unknown;
6
6
  }
7
7
 
8
+ export type ReorderDelta = "up" | "down" | "top" | "bottom";
9
+
8
10
  /**
9
11
  * Maintains custom session ordering for reorder-session commands.
10
12
  * Stores an ordered list of session names. The `apply` method takes
@@ -59,8 +61,8 @@ export class SessionOrder {
59
61
  }
60
62
  }
61
63
 
62
- /** Move a session: delta -1 = up, 1 = down, "top" / "bottom" = jump to end. */
63
- reorder(name: string, delta: -1 | 1 | "top" | "bottom"): void {
64
+ /** Move a session up/down by one, or jump it to top/bottom of the order. */
65
+ reorder(name: string, delta: ReorderDelta): void {
64
66
  const idx = this.order.indexOf(name);
65
67
  if (idx === -1) return;
66
68
  if (delta === "top") {
@@ -68,7 +70,8 @@ export class SessionOrder {
68
70
  } else if (delta === "bottom") {
69
71
  this.order = [...this.order.filter((n) => n !== name), name];
70
72
  } else {
71
- const newIdx = idx + delta;
73
+ const step = delta === "up" ? -1 : 1;
74
+ const newIdx = idx + step;
72
75
  if (newIdx < 0 || newIdx >= this.order.length) return;
73
76
  [this.order[idx], this.order[newIdx]] = [this.order[newIdx]!, this.order[idx]!];
74
77
  }
@@ -1,4 +1,7 @@
1
1
  import type { AgentStatus, AgentEvent } from "./contracts/agent";
2
+ import type { ReorderDelta } from "./server/session-order";
3
+
4
+ export type { ReorderDelta };
2
5
 
3
6
  export const DEFAULT_SERVER_PORT = 4201;
4
7
  export const DEFAULT_SERVER_HOST = "127.0.0.1";
@@ -112,7 +115,7 @@ export type ClientCommand =
112
115
  | { type: "switch-index"; index: number }
113
116
  | { type: "new-session" }
114
117
  | { type: "kill-session"; name: string }
115
- | { type: "reorder-session"; name: string; delta: -1 | 1 | "top" | "bottom" }
118
+ | { type: "reorder-session"; name: string; delta: ReorderDelta }
116
119
  | { type: "refresh" }
117
120
  | { type: "move-focus"; delta: -1 | 1 }
118
121
  | { type: "focus-session"; name: string }
@@ -0,0 +1,4 @@
1
+ /** Truncate a string to `max` chars, appending an ellipsis character if clipped. */
2
+ export function truncate(s: string, max: number): string {
3
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
4
+ }
@@ -1,33 +0,0 @@
1
- import type { Theme } from "@tt-agentboard/runtime";
2
-
3
- export const CACHE_BAR_WIDTH = 10;
4
- export const CACHE_BAR_FILLED = "▰";
5
- export const CACHE_BAR_EMPTY = "▱";
6
-
7
- /** Render a drain-down bar: full = freshly cached, empty = expired. */
8
- export function cacheBar(expiresAt: number, ttlMs: number, now: number): string {
9
- const remaining = expiresAt - now;
10
- if (remaining <= 0 || ttlMs <= 0) return CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH);
11
- const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
12
- const filled = Math.round(fraction * CACHE_BAR_WIDTH);
13
- return CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
14
- }
15
-
16
- export function shortModel(model: string): string {
17
- if (!model) return "";
18
- return model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
19
- }
20
-
21
- export function cacheBarColor(
22
- expiresAt: number,
23
- ttlMs: number,
24
- now: number,
25
- palette: Theme["palette"],
26
- ): string {
27
- const remaining = expiresAt - now;
28
- if (remaining <= 0 || ttlMs <= 0) return palette.overlay0;
29
- const fraction = remaining / ttlMs;
30
- if (fraction > 0.5) return palette.green;
31
- if (fraction > 0.2) return palette.yellow;
32
- return palette.peach;
33
- }
@@ -1,70 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import type { SessionData, AgentEvent } from "@tt-agentboard/runtime";
3
- import { computeSessionStatusCounts } from "./session-status";
4
-
5
- function makeSession(agentStatus?: AgentEvent["status"]): SessionData {
6
- return {
7
- name: "test",
8
- createdAt: Date.now(),
9
- dir: "/tmp",
10
- branch: "main",
11
- dirty: false,
12
- isWorktree: false,
13
- filesChanged: 0,
14
- linesAdded: 0,
15
- linesRemoved: 0,
16
- commitsDelta: 0,
17
- unseen: false,
18
- panes: 1,
19
- ports: [],
20
- windows: 1,
21
- uptime: "0s",
22
- agentState: agentStatus
23
- ? { agent: "claude", session: "test", status: agentStatus, ts: Date.now() }
24
- : null,
25
- agents: [],
26
- eventTimestamps: [],
27
- };
28
- }
29
-
30
- describe("computeSessionStatusCounts", () => {
31
- it("returns all zeros for empty sessions", () => {
32
- expect(computeSessionStatusCounts([])).toEqual({ active: 0, error: 0, idle: 0 });
33
- });
34
-
35
- it("counts running sessions as active", () => {
36
- const sessions = [makeSession("running"), makeSession("running")];
37
- expect(computeSessionStatusCounts(sessions)).toEqual({ active: 2, error: 0, idle: 0 });
38
- });
39
-
40
- it("counts waiting sessions as active", () => {
41
- const sessions = [makeSession("waiting")];
42
- expect(computeSessionStatusCounts(sessions)).toEqual({ active: 1, error: 0, idle: 0 });
43
- });
44
-
45
- it("counts error sessions", () => {
46
- const sessions = [makeSession("error")];
47
- expect(computeSessionStatusCounts(sessions)).toEqual({ active: 0, error: 1, idle: 0 });
48
- });
49
-
50
- it("counts idle, done, interrupted, and null agentState as idle", () => {
51
- const sessions = [
52
- makeSession("idle"),
53
- makeSession("done"),
54
- makeSession("interrupted"),
55
- makeSession(undefined),
56
- ];
57
- expect(computeSessionStatusCounts(sessions)).toEqual({ active: 0, error: 0, idle: 4 });
58
- });
59
-
60
- it("counts mixed statuses correctly", () => {
61
- const sessions = [
62
- makeSession("running"),
63
- makeSession("error"),
64
- makeSession("idle"),
65
- makeSession("waiting"),
66
- makeSession("done"),
67
- ];
68
- expect(computeSessionStatusCounts(sessions)).toEqual({ active: 2, error: 1, idle: 2 });
69
- });
70
- });
@@ -1,19 +0,0 @@
1
- import type { SessionData } from "@tt-agentboard/runtime";
2
- import type { SessionStatusCounts } from "./components/StatusBar";
3
-
4
- export function computeSessionStatusCounts(sessions: SessionData[]): SessionStatusCounts {
5
- let active = 0;
6
- let error = 0;
7
- let idle = 0;
8
- for (const s of sessions) {
9
- const status = s.agentState?.status;
10
- if (status === "running" || status === "waiting" || status === "question") {
11
- active++;
12
- } else if (status === "error") {
13
- error++;
14
- } else {
15
- idle++;
16
- }
17
- }
18
- return { active, error, idle };
19
- }