@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 +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 +39 -52
- 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,
|
|
@@ -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
|
|
379
|
-
//
|
|
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
|
|
382
|
-
sessions.some((s) => s.agents?.some((a) => a.
|
|
387
|
+
const needsTicker = createMemo(() =>
|
|
388
|
+
sessions.some((s) => s.agents?.some((a) => a.status === "running")),
|
|
383
389
|
);
|
|
384
390
|
createEffect(() => {
|
|
385
|
-
if (!
|
|
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
|
-
<
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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+↑↓", "
|
|
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={
|
|
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="
|
|
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>
|
|
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
|
|
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
|
-
}
|