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