@towles/tool 0.0.124 → 0.0.126

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.124",
3
+ "version": "0.0.126",
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
 
@@ -4,8 +4,10 @@ import type { AgentStatus, SessionData, Theme } from "@tt-agentboard/runtime";
4
4
  import { truncate } from "@tt-agentboard/runtime";
5
5
  import { UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
6
6
  import { DiffStats } from "./DiffStats";
7
- import { cacheBarVisual, shortModel } from "./cache-bar";
7
+ import { shortModel } from "./short-model";
8
+ import { formatElapsed } from "./elapsed";
8
9
  import { liveStatusIcon, unseenTerminalColor } from "./status-visuals";
10
+ import { familyColor } from "./family-color";
9
11
 
10
12
  const STATUS_TEXT: Record<AgentStatus, string> = {
11
13
  idle: "",
@@ -19,7 +21,6 @@ const STATUS_TEXT: Record<AgentStatus, string> = {
19
21
 
20
22
  export interface SessionCardProps {
21
23
  session: SessionData;
22
- index: number;
23
24
  isFocused: boolean;
24
25
  isCurrent: boolean;
25
26
  spinIdx: Accessor<number>;
@@ -67,15 +68,12 @@ export function SessionCard(props: SessionCardProps) {
67
68
  return SC()[status()];
68
69
  };
69
70
 
71
+ const familyHue = () => familyColor(props.session.name, P());
72
+
70
73
  const nameColor = () => {
71
74
  if (props.isFocused) return P().text;
72
75
  if (props.isCurrent) return P().subtext1;
73
- return P().subtext0;
74
- };
75
-
76
- const indexColor = () => {
77
- if (props.isFocused) return P().subtext0;
78
- return P().surface2;
76
+ return familyHue();
79
77
  };
80
78
 
81
79
  const truncName = () => truncate(props.session.name, 18);
@@ -105,7 +103,7 @@ export function SessionCard(props: SessionCardProps) {
105
103
  const metaTone = () => props.session.metadata?.status?.tone;
106
104
 
107
105
  const bgColor = () => {
108
- if (props.isFocused) return P().surface1;
106
+ if (props.isFocused) return P().surface0;
109
107
  return "transparent";
110
108
  };
111
109
 
@@ -121,9 +119,13 @@ export function SessionCard(props: SessionCardProps) {
121
119
  >
122
120
  <text style={{ fg: accentColor() }}>{accentColor() === "transparent" ? " " : "▌"}</text>
123
121
 
124
- <box width={3} flexShrink={0}>
125
- <text style={{ fg: indexColor() }}>{String(props.index).padStart(2)}</text>
126
- </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>
127
129
 
128
130
  <box flexDirection="column" flexGrow={1} paddingRight={1}>
129
131
  <box flexDirection="row">
@@ -241,8 +243,8 @@ function AgentRow(props: AgentRowProps) {
241
243
  });
242
244
 
243
245
  const bgColor = () => {
244
- if (isFlash()) return P().surface1;
245
- if (props.isKeyboardFocused) return P().surface0;
246
+ if (isFlash()) return P().surface2;
247
+ if (props.isKeyboardFocused) return P().surface1;
246
248
  return "transparent";
247
249
  };
248
250
 
@@ -260,15 +262,17 @@ function AgentRow(props: AgentRowProps) {
260
262
  <box flexDirection="row">
261
263
  <text flexGrow={1} truncate>
262
264
  <span style={{ fg: color() }}>{icon()}</span>
263
- <span
264
- style={{
265
- fg: props.isKeyboardFocused ? P().text : P().subtext1,
266
- attributes: props.isKeyboardFocused ? BOLD : undefined,
267
- }}
268
- >
269
- {" "}
270
- {props.agent.agent}
271
- </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>
272
276
  </text>
273
277
  <Show when={!isUnseen()}>
274
278
  <text flexShrink={0}>
@@ -290,37 +294,30 @@ function AgentRow(props: AgentRowProps) {
290
294
  </box>
291
295
 
292
296
  <Show when={props.agent.threadName}>
293
- <text truncate>
294
- <span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
295
- {props.agent.threadName!.replace(/\s+/g, " ").trim()}
296
- </span>
297
- </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>
298
304
  </Show>
299
305
 
300
- <Show when={props.agent.details}>
306
+ <Show when={props.agent.status === "running" && props.agent.details}>
301
307
  {(d) => {
302
308
  const details = d();
303
309
  const model = () => (details.model ? shortModel(details.model) : "");
304
- const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
305
- const visual = () =>
306
- hasCache()
307
- ? cacheBarVisual(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
308
- : null;
310
+ const tool = () => details.lastTool;
309
311
  return (
310
- <Show when={model() || hasCache()}>
312
+ <Show when={model() || tool()}>
311
313
  <text truncate>
312
314
  <Show when={model()}>
313
315
  <span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
314
316
  </Show>
315
- <Show when={visual()}>
316
- {(v) => (
317
- <>
318
- <span style={{ fg: P().overlay0, attributes: DIM }}>
319
- {model() ? " · cache " : "cache "}
320
- </span>
321
- <span style={{ fg: v().color }}>{v().bar}</span>
322
- </>
323
- )}
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>
324
321
  </Show>
325
322
  </text>
326
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
+ }
@@ -24,7 +24,6 @@ import type {
24
24
  } from "@tt-agentboard/runtime";
25
25
  import { SessionCard } from "./components/SessionCard";
26
26
  import { StatusBar } from "./components/StatusBar";
27
- import { computeSessionStatusCounts } from "./session-status";
28
27
  import {
29
28
  detectMuxContext,
30
29
  refocusMainPane,
@@ -245,10 +244,17 @@ function App() {
245
244
  if (!data?.dir) return;
246
245
  const editor = preferredEditor();
247
246
  try {
247
+ // Strip tmux env vars so the editor (and any terminal it opens) isn't
248
+ // locked into our outer tmux session.
249
+ const cleanEnv = { ...process.env };
250
+ delete cleanEnv.TMUX;
251
+ delete cleanEnv.TMUX_PANE;
252
+ delete cleanEnv.TMUX_PLUGIN_MANAGER_PATH;
248
253
  const proc = Bun.spawn([editor, data.dir], {
249
254
  stdout: "ignore",
250
255
  stderr: "ignore",
251
256
  stdin: "ignore",
257
+ env: cleanEnv,
252
258
  });
253
259
  showToast(`opening ${data.dir} in ${editor}`, "success");
254
260
  void proc.exited.then((code) => {
@@ -375,14 +381,14 @@ function App() {
375
381
  onCleanup(() => clearInterval(interval));
376
382
  });
377
383
 
378
- // Shared 1s clock for cache-countdown bars. One ticker for all cards,
379
- // gated on whether any agent across all sessions is actively cached.
384
+ // Shared 1s clock for elapsed-time displays.
385
+ // Ticks only while any agent is running.
380
386
  const [now, setNow] = createSignal(Date.now());
381
- const hasActiveCache = createMemo(() =>
382
- sessions.some((s) => s.agents?.some((a) => a.details?.cacheExpiresAt != null)),
387
+ const needsTicker = createMemo(() =>
388
+ sessions.some((s) => s.agents?.some((a) => a.status === "running")),
383
389
  );
384
390
  createEffect(() => {
385
- if (!hasActiveCache()) return;
391
+ if (!needsTicker()) return;
386
392
  const id = setInterval(() => setNow(Date.now()), 1000);
387
393
  onCleanup(() => clearInterval(id));
388
394
  });
@@ -411,6 +417,13 @@ function App() {
411
417
  }
412
418
 
413
419
  // --- Normal mode keybindings ---
420
+ // Help: "?" arrives as {name: "/", shift: true} under Kitty keyboard protocol,
421
+ // or as {name: "?"} in raw mode. Match both.
422
+ if (key.name === "?" || (key.name === "/" && key.shift)) {
423
+ setModal("help");
424
+ return;
425
+ }
426
+
414
427
  // Alt+Up/Down → reorder session ±1. Alt+Shift+Up/Down → jump to top/bottom.
415
428
  if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
416
429
  const focused = focusedSession();
@@ -505,9 +518,6 @@ function App() {
505
518
  case "n":
506
519
  createNewSession();
507
520
  break;
508
- case "?":
509
- setModal("help");
510
- break;
511
521
  default: {
512
522
  if (key.number) {
513
523
  const idx = Number.parseInt(key.name, 10) - 1;
@@ -528,7 +538,6 @@ function App() {
528
538
  );
529
539
 
530
540
  const unseenCount = createMemo(() => sessions.filter((s) => s.unseen).length);
531
- const sessionStatusCounts = createMemo(() => computeSessionStatusCounts(sessions));
532
541
 
533
542
  const isFocused = createSelector(focusedSession);
534
543
 
@@ -540,7 +549,6 @@ function App() {
540
549
  runningCount={runningAgentCount()}
541
550
  errorCount={errorAgentCount()}
542
551
  unseenCount={unseenCount()}
543
- sessionStatusCounts={sessionStatusCounts()}
544
552
  theme={theme}
545
553
  />
546
554
 
@@ -550,7 +558,6 @@ function App() {
550
558
  {(session, i) => (
551
559
  <SessionCard
552
560
  session={session}
553
- index={i() + 1}
554
561
  isFocused={isFocused(session.name)}
555
562
  isCurrent={session.name === currentSession()}
556
563
  spinIdx={spinIdx}
@@ -620,20 +627,12 @@ function App() {
620
627
  />
621
628
  }
622
629
  >
623
- <KeyHints
624
- palette={P}
625
- hints={[
626
- ["⇥", "cycle"],
627
- ["⏎", "switch"],
628
- ["→", "agents"],
629
- ["n", "new"],
630
- ["e", "edit"],
631
- ["x", "kill"],
632
- ["r", "refresh"],
633
- ["q", "quit"],
634
- ["?", "help"],
635
- ]}
636
- />
630
+ <box height={1}>
631
+ <text>
632
+ <span style={{ fg: P().overlay0 }}>?</span>
633
+ <span style={{ fg: P().overlay1 }}> help</span>
634
+ </text>
635
+ </box>
637
636
  </Show>
638
637
  </box>
639
638
 
@@ -696,16 +695,10 @@ const HELP_KEYS: [string, string][] = [
696
695
  ["→/l", "Agents panel"],
697
696
  ["←/h/Esc", "Back to sessions"],
698
697
  ["Alt+↑↓", "Reorder sessions"],
699
- ["Alt+Shift+↑↓", "Move to top/bottom"],
698
+ ["Alt+Shift+↑↓", "To top/bottom"],
700
699
  ["q", "Quit"],
701
700
  ];
702
701
 
703
- const HELP_COLS = 2;
704
- const HELP_ROWS = Math.ceil(HELP_KEYS.length / HELP_COLS);
705
- const HELP_COLUMNS = Array.from({ length: HELP_COLS }, (_, c) =>
706
- HELP_KEYS.slice(c * HELP_ROWS, (c + 1) * HELP_ROWS),
707
- );
708
-
709
702
  function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () => void }) {
710
703
  const P = () => props.palette();
711
704
  return (
@@ -726,7 +719,7 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
726
719
  backgroundColor={P().mantle}
727
720
  padding={1}
728
721
  flexDirection="column"
729
- width={56}
722
+ width={32}
730
723
  >
731
724
  <text>
732
725
  <span style={{ fg: P().blue, attributes: BOLD }}>Keybindings</span>
@@ -734,24 +727,18 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
734
727
  <box height={1}>
735
728
  <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
736
729
  </box>
737
- <box flexDirection="row">
738
- <For each={HELP_COLUMNS}>
739
- {(col) => (
740
- <box flexDirection="column" flexGrow={1}>
741
- <For each={col}>
742
- {([key, desc]) => (
743
- <box flexDirection="row" paddingLeft={1}>
744
- <box width={12} flexShrink={0}>
745
- <text>
746
- <span style={{ fg: P().sky }}>{key}</span>
747
- </text>
748
- </box>
749
- <text truncate>
750
- <span style={{ fg: P().subtext0 }}>{desc}</span>
751
- </text>
752
- </box>
753
- )}
754
- </For>
730
+ <box flexDirection="column">
731
+ <For each={HELP_KEYS}>
732
+ {([key, desc]) => (
733
+ <box flexDirection="row">
734
+ <box width={14} flexShrink={0}>
735
+ <text truncate>
736
+ <span style={{ fg: P().sky }}>{key}</span>
737
+ </text>
738
+ </box>
739
+ <text truncate>
740
+ <span style={{ fg: P().subtext0 }}>{desc}</span>
741
+ </text>
755
742
  </box>
756
743
  )}
757
744
  </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 {
@@ -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
- export interface CacheBarVisual {
8
- bar: string;
9
- color: string;
10
- }
11
-
12
- /** Drain-down bar + traffic-light color: full/green when fresh, empty/grey when expired. */
13
- export function cacheBarVisual(
14
- expiresAt: number,
15
- ttlMs: number,
16
- now: number,
17
- palette: Theme["palette"],
18
- ): CacheBarVisual {
19
- const remaining = expiresAt - now;
20
- if (remaining <= 0 || ttlMs <= 0) {
21
- return { bar: CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH), color: palette.overlay0 };
22
- }
23
- const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
24
- const filled = Math.round(fraction * CACHE_BAR_WIDTH);
25
- const bar = CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
26
- const color = fraction > 0.5 ? palette.green : fraction > 0.2 ? palette.yellow : palette.peach;
27
- return { bar, color };
28
- }
29
-
30
- export function shortModel(model: string): string {
31
- if (!model) return "";
32
- return model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
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
- }