@towles/tool 0.0.123 → 0.0.124
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/apps/tui/src/components/SessionCard.tsx +34 -54
- package/packages/agentboard/apps/tui/src/components/cache-bar.ts +18 -18
- package/packages/agentboard/apps/tui/src/components/status-visuals.ts +17 -0
- package/packages/agentboard/apps/tui/src/index.tsx +22 -8
- 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/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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 { cacheBarVisual, shortModel } from "./cache-bar";
|
|
8
|
+
import { liveStatusIcon, unseenTerminalColor } from "./status-visuals";
|
|
7
9
|
|
|
8
10
|
const STATUS_TEXT: Record<AgentStatus, string> = {
|
|
9
11
|
idle: "",
|
|
@@ -21,6 +23,7 @@ export interface SessionCardProps {
|
|
|
21
23
|
isFocused: boolean;
|
|
22
24
|
isCurrent: boolean;
|
|
23
25
|
spinIdx: Accessor<number>;
|
|
26
|
+
now: Accessor<number>;
|
|
24
27
|
theme: Accessor<Theme>;
|
|
25
28
|
statusColors: Accessor<Theme["status"]>;
|
|
26
29
|
focusedAgentIdx: number;
|
|
@@ -42,7 +45,7 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
42
45
|
|
|
43
46
|
const accentColor = () => {
|
|
44
47
|
if (props.isCurrent) return P().green;
|
|
45
|
-
if (isUnseenTerminal()) return
|
|
48
|
+
if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
|
|
46
49
|
const s = status();
|
|
47
50
|
if (s === "error") return P().red;
|
|
48
51
|
if (s === "interrupted") return P().peach;
|
|
@@ -53,24 +56,14 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
53
56
|
return "transparent";
|
|
54
57
|
};
|
|
55
58
|
|
|
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
59
|
const statusIcon = () => {
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
if (s === "question") return "?";
|
|
68
|
-
if (isUnseenTerminal()) return UNSEEN_ICON;
|
|
69
|
-
return "";
|
|
60
|
+
const live = liveStatusIcon(status(), props.spinIdx());
|
|
61
|
+
if (live) return live;
|
|
62
|
+
return isUnseenTerminal() ? UNSEEN_ICON : "";
|
|
70
63
|
};
|
|
71
64
|
|
|
72
65
|
const statusColor = () => {
|
|
73
|
-
if (isUnseenTerminal()) return
|
|
66
|
+
if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
|
|
74
67
|
return SC()[status()];
|
|
75
68
|
};
|
|
76
69
|
|
|
@@ -85,16 +78,8 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
85
78
|
return P().surface2;
|
|
86
79
|
};
|
|
87
80
|
|
|
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
|
-
};
|
|
81
|
+
const truncName = () => truncate(props.session.name, 18);
|
|
82
|
+
const truncBranch = () => (props.session.branch ? truncate(props.session.branch, 30) : "");
|
|
98
83
|
|
|
99
84
|
const hasDiff = () => {
|
|
100
85
|
const { linesAdded, linesRemoved, commitsDelta, filesChanged } = props.session;
|
|
@@ -126,14 +111,6 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
126
111
|
|
|
127
112
|
const agents = () => props.session.agents ?? [];
|
|
128
113
|
|
|
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
114
|
return (
|
|
138
115
|
<box flexDirection="column" flexShrink={0}>
|
|
139
116
|
<box
|
|
@@ -196,7 +173,7 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
196
173
|
palette={P}
|
|
197
174
|
statusColors={props.statusColors}
|
|
198
175
|
spinIdx={props.spinIdx}
|
|
199
|
-
now={now}
|
|
176
|
+
now={props.now}
|
|
200
177
|
isKeyboardFocused={i() === props.focusedAgentIdx}
|
|
201
178
|
onDismiss={() => props.onDismissAgent(agent)}
|
|
202
179
|
onFocusPane={() => props.onFocusAgentPane(agent)}
|
|
@@ -233,19 +210,20 @@ function AgentRow(props: AgentRowProps) {
|
|
|
233
210
|
|
|
234
211
|
const icon = () => {
|
|
235
212
|
if (isUnseen()) return UNSEEN_ICON;
|
|
236
|
-
if (isTerminal())
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return "○";
|
|
213
|
+
if (isTerminal()) {
|
|
214
|
+
if (props.agent.status === "done") return "✓";
|
|
215
|
+
if (props.agent.status === "error") return "✗";
|
|
216
|
+
return "⚠";
|
|
217
|
+
}
|
|
218
|
+
return liveStatusIcon(props.agent.status, props.spinIdx()) || "○";
|
|
242
219
|
};
|
|
243
220
|
|
|
244
221
|
const color = () => {
|
|
245
222
|
if (isTerminal()) {
|
|
223
|
+
if (isUnseen()) return unseenTerminalColor(props.agent.status, P());
|
|
246
224
|
if (props.agent.status === "error") return P().red;
|
|
247
225
|
if (props.agent.status === "interrupted") return P().peach;
|
|
248
|
-
return
|
|
226
|
+
return P().green;
|
|
249
227
|
}
|
|
250
228
|
return SC()[props.agent.status];
|
|
251
229
|
};
|
|
@@ -324,23 +302,25 @@ function AgentRow(props: AgentRowProps) {
|
|
|
324
302
|
const details = d();
|
|
325
303
|
const model = () => (details.model ? shortModel(details.model) : "");
|
|
326
304
|
const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
|
|
327
|
-
const
|
|
328
|
-
hasCache() ? cacheBar(details.cacheExpiresAt!, details.cacheTtlMs!, props.now()) : "";
|
|
329
|
-
const barColor = () =>
|
|
305
|
+
const visual = () =>
|
|
330
306
|
hasCache()
|
|
331
|
-
?
|
|
332
|
-
:
|
|
307
|
+
? cacheBarVisual(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
|
|
308
|
+
: null;
|
|
333
309
|
return (
|
|
334
310
|
<Show when={model() || hasCache()}>
|
|
335
311
|
<text truncate>
|
|
336
312
|
<Show when={model()}>
|
|
337
313
|
<span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
|
|
338
314
|
</Show>
|
|
339
|
-
<Show when={
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
315
|
+
<Show when={visual()}>
|
|
316
|
+
{(v) => (
|
|
317
|
+
<>
|
|
318
|
+
<span style={{ fg: P().overlay0, attributes: DIM }}>
|
|
319
|
+
{model() ? " · cache " : "cache "}
|
|
320
|
+
</span>
|
|
321
|
+
<span style={{ fg: v().color }}>{v().bar}</span>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
344
324
|
</Show>
|
|
345
325
|
</text>
|
|
346
326
|
</Show>
|
|
@@ -4,30 +4,30 @@ export const CACHE_BAR_WIDTH = 10;
|
|
|
4
4
|
export const CACHE_BAR_FILLED = "▰";
|
|
5
5
|
export const CACHE_BAR_EMPTY = "▱";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
|
9
19
|
const remaining = expiresAt - now;
|
|
10
|
-
if (remaining <= 0 || ttlMs <= 0)
|
|
20
|
+
if (remaining <= 0 || ttlMs <= 0) {
|
|
21
|
+
return { bar: CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH), color: palette.overlay0 };
|
|
22
|
+
}
|
|
11
23
|
const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
|
|
12
24
|
const filled = Math.round(fraction * CACHE_BAR_WIDTH);
|
|
13
|
-
|
|
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 };
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
export function shortModel(model: string): string {
|
|
17
31
|
if (!model) return "";
|
|
18
32
|
return model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
|
|
19
33
|
}
|
|
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
|
-
}
|
|
@@ -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,7 +15,13 @@ 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
27
|
import { computeSessionStatusCounts } from "./session-status";
|
|
@@ -369,6 +375,18 @@ function App() {
|
|
|
369
375
|
onCleanup(() => clearInterval(interval));
|
|
370
376
|
});
|
|
371
377
|
|
|
378
|
+
// Shared 1s clock for cache-countdown bars. One ticker for all cards,
|
|
379
|
+
// gated on whether any agent across all sessions is actively cached.
|
|
380
|
+
const [now, setNow] = createSignal(Date.now());
|
|
381
|
+
const hasActiveCache = createMemo(() =>
|
|
382
|
+
sessions.some((s) => s.agents?.some((a) => a.details?.cacheExpiresAt != null)),
|
|
383
|
+
);
|
|
384
|
+
createEffect(() => {
|
|
385
|
+
if (!hasActiveCache()) return;
|
|
386
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
387
|
+
onCleanup(() => clearInterval(id));
|
|
388
|
+
});
|
|
389
|
+
|
|
372
390
|
useKeyboard((key) => {
|
|
373
391
|
const currentModal = modal();
|
|
374
392
|
|
|
@@ -397,13 +415,8 @@ function App() {
|
|
|
397
415
|
if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
|
|
398
416
|
const focused = focusedSession();
|
|
399
417
|
if (focused) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
? "top"
|
|
403
|
-
: "bottom"
|
|
404
|
-
: key.name === "up"
|
|
405
|
-
? -1
|
|
406
|
-
: 1;
|
|
418
|
+
const up = key.name === "up";
|
|
419
|
+
const delta: ReorderDelta = key.shift ? (up ? "top" : "bottom") : up ? "up" : "down";
|
|
407
420
|
send({ type: "reorder-session", name: focused, delta });
|
|
408
421
|
}
|
|
409
422
|
return;
|
|
@@ -541,6 +554,7 @@ function App() {
|
|
|
541
554
|
isFocused={isFocused(session.name)}
|
|
542
555
|
isCurrent={session.name === currentSession()}
|
|
543
556
|
spinIdx={spinIdx}
|
|
557
|
+
now={now}
|
|
544
558
|
theme={theme}
|
|
545
559
|
statusColors={S}
|
|
546
560
|
focusedAgentIdx={
|
|
@@ -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 }
|