@towles/tool 0.0.124 → 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.
- package/package.json +1 -1
- package/packages/agentboard/README.md +1 -1
- package/packages/agentboard/apps/tui/src/components/SessionCard.tsx +41 -44
- package/packages/agentboard/apps/tui/src/components/StatusBar.tsx +0 -35
- package/packages/agentboard/apps/tui/src/components/elapsed.test.ts +30 -0
- package/packages/agentboard/apps/tui/src/components/elapsed.ts +9 -0
- package/packages/agentboard/apps/tui/src/components/family-color.test.ts +70 -0
- package/packages/agentboard/apps/tui/src/components/family-color.ts +32 -0
- package/packages/agentboard/apps/tui/src/components/short-model.ts +4 -0
- package/packages/agentboard/apps/tui/src/index.tsx +25 -49
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.test.ts +77 -1
- package/packages/agentboard/packages/runtime/src/agents/watchers/claude-code.ts +35 -3
- package/packages/agentboard/packages/runtime/src/contracts/agent.ts +2 -0
- package/packages/agentboard/apps/tui/src/components/cache-bar.ts +0 -33
- package/packages/agentboard/apps/tui/src/session-status.test.ts +0 -70
- package/packages/agentboard/apps/tui/src/session-status.ts +0 -19
package/package.json
CHANGED
|
@@ -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/
|
|
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 {
|
|
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
|
|
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().
|
|
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
|
-
<
|
|
125
|
-
<
|
|
126
|
-
|
|
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().
|
|
245
|
-
if (props.isKeyboardFocused) return P().
|
|
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
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
<
|
|
294
|
-
<
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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() ||
|
|
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={
|
|
316
|
-
{(
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
@@ -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,
|
|
@@ -375,14 +374,14 @@ function App() {
|
|
|
375
374
|
onCleanup(() => clearInterval(interval));
|
|
376
375
|
});
|
|
377
376
|
|
|
378
|
-
// Shared 1s clock for
|
|
379
|
-
//
|
|
377
|
+
// Shared 1s clock for elapsed-time displays.
|
|
378
|
+
// Ticks only while any agent is running.
|
|
380
379
|
const [now, setNow] = createSignal(Date.now());
|
|
381
|
-
const
|
|
382
|
-
sessions.some((s) => s.agents?.some((a) => a.
|
|
380
|
+
const needsTicker = createMemo(() =>
|
|
381
|
+
sessions.some((s) => s.agents?.some((a) => a.status === "running")),
|
|
383
382
|
);
|
|
384
383
|
createEffect(() => {
|
|
385
|
-
if (!
|
|
384
|
+
if (!needsTicker()) return;
|
|
386
385
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
387
386
|
onCleanup(() => clearInterval(id));
|
|
388
387
|
});
|
|
@@ -528,7 +527,6 @@ function App() {
|
|
|
528
527
|
);
|
|
529
528
|
|
|
530
529
|
const unseenCount = createMemo(() => sessions.filter((s) => s.unseen).length);
|
|
531
|
-
const sessionStatusCounts = createMemo(() => computeSessionStatusCounts(sessions));
|
|
532
530
|
|
|
533
531
|
const isFocused = createSelector(focusedSession);
|
|
534
532
|
|
|
@@ -540,7 +538,6 @@ function App() {
|
|
|
540
538
|
runningCount={runningAgentCount()}
|
|
541
539
|
errorCount={errorAgentCount()}
|
|
542
540
|
unseenCount={unseenCount()}
|
|
543
|
-
sessionStatusCounts={sessionStatusCounts()}
|
|
544
541
|
theme={theme}
|
|
545
542
|
/>
|
|
546
543
|
|
|
@@ -550,7 +547,6 @@ function App() {
|
|
|
550
547
|
{(session, i) => (
|
|
551
548
|
<SessionCard
|
|
552
549
|
session={session}
|
|
553
|
-
index={i() + 1}
|
|
554
550
|
isFocused={isFocused(session.name)}
|
|
555
551
|
isCurrent={session.name === currentSession()}
|
|
556
552
|
spinIdx={spinIdx}
|
|
@@ -620,20 +616,12 @@ function App() {
|
|
|
620
616
|
/>
|
|
621
617
|
}
|
|
622
618
|
>
|
|
623
|
-
<
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
["n", "new"],
|
|
630
|
-
["e", "edit"],
|
|
631
|
-
["x", "kill"],
|
|
632
|
-
["r", "refresh"],
|
|
633
|
-
["q", "quit"],
|
|
634
|
-
["?", "help"],
|
|
635
|
-
]}
|
|
636
|
-
/>
|
|
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>
|
|
637
625
|
</Show>
|
|
638
626
|
</box>
|
|
639
627
|
|
|
@@ -696,16 +684,10 @@ const HELP_KEYS: [string, string][] = [
|
|
|
696
684
|
["→/l", "Agents panel"],
|
|
697
685
|
["←/h/Esc", "Back to sessions"],
|
|
698
686
|
["Alt+↑↓", "Reorder sessions"],
|
|
699
|
-
["Alt+Shift+↑↓", "
|
|
687
|
+
["Alt+Shift+↑↓", "To top/bottom"],
|
|
700
688
|
["q", "Quit"],
|
|
701
689
|
];
|
|
702
690
|
|
|
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
691
|
function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () => void }) {
|
|
710
692
|
const P = () => props.palette();
|
|
711
693
|
return (
|
|
@@ -726,7 +708,7 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
|
|
|
726
708
|
backgroundColor={P().mantle}
|
|
727
709
|
padding={1}
|
|
728
710
|
flexDirection="column"
|
|
729
|
-
width={
|
|
711
|
+
width={32}
|
|
730
712
|
>
|
|
731
713
|
<text>
|
|
732
714
|
<span style={{ fg: P().blue, attributes: BOLD }}>Keybindings</span>
|
|
@@ -734,24 +716,18 @@ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () =
|
|
|
734
716
|
<box height={1}>
|
|
735
717
|
<text style={{ fg: P().surface2 }}>{DIVIDER}</text>
|
|
736
718
|
</box>
|
|
737
|
-
<box flexDirection="
|
|
738
|
-
<For each={
|
|
739
|
-
{(
|
|
740
|
-
<box flexDirection="
|
|
741
|
-
<
|
|
742
|
-
|
|
743
|
-
<
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
<text truncate>
|
|
750
|
-
<span style={{ fg: P().subtext0 }}>{desc}</span>
|
|
751
|
-
</text>
|
|
752
|
-
</box>
|
|
753
|
-
)}
|
|
754
|
-
</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>
|
|
755
731
|
</box>
|
|
756
732
|
)}
|
|
757
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
|
|
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
|
|
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
|
|
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
|
-
}
|