@towles/tool 0.0.122 → 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/README.md +11 -16
- package/packages/agentboard/apps/tui/src/components/SessionCard.tsx +187 -36
- package/packages/agentboard/apps/tui/src/components/cache-bar.ts +33 -0
- package/packages/agentboard/apps/tui/src/components/status-visuals.ts +17 -0
- package/packages/agentboard/apps/tui/src/constants.ts +0 -11
- package/packages/agentboard/apps/tui/src/index.tsx +30 -145
- package/packages/agentboard/packages/runtime/src/config.ts +0 -2
- package/packages/agentboard/packages/runtime/src/index.ts +2 -0
- package/packages/agentboard/packages/runtime/src/server/index.ts +12 -2
- package/packages/agentboard/packages/runtime/src/server/metadata-store.ts +2 -5
- package/packages/agentboard/packages/runtime/src/server/session-order.ts +14 -6
- 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/DetailPanel.tsx +0 -414
- package/packages/agentboard/apps/tui/src/detail-panel-height.ts +0 -21
package/package.json
CHANGED
|
@@ -83,32 +83,27 @@ WebSocket server on `127.0.0.1:4201`. Auto-started by the TUI or tmux scripts.
|
|
|
83
83
|
Solid.js app rendered via OpenTUI. Connects to server over WebSocket.
|
|
84
84
|
|
|
85
85
|
- Session cards with accent bars, status icons, branch info
|
|
86
|
-
-
|
|
87
|
-
- Mouse support (click
|
|
86
|
+
- Inline agent rows per card with cache-countdown bar for Claude Code panes
|
|
87
|
+
- Mouse support (click to focus, dismiss)
|
|
88
88
|
- Help overlay (`?`)
|
|
89
89
|
|
|
90
90
|
#### TUI Components
|
|
91
91
|
|
|
92
|
-
**`SessionCard`** (`components/SessionCard.tsx`) — session list item
|
|
92
|
+
**`SessionCard`** (`components/SessionCard.tsx`) — session list item with inline agent rows
|
|
93
93
|
|
|
94
94
|
- Row 1: session name (truncated to 18 chars) + status icon (braille spinner when running, `●` for unseen terminal states)
|
|
95
|
-
- Row 2: git branch
|
|
96
|
-
- Row 3:
|
|
95
|
+
- Row 2: git branch
|
|
96
|
+
- Row 3: git diff stats
|
|
97
|
+
- Row 4: metadata summary (status text + progress like `3/5` or `42%`)
|
|
98
|
+
- Agent rows (one per pane): status icon + name + status text + dismiss `✕`, thread name, and for Claude Code agents a `model · cache ▰▰▱…` drain-down bar
|
|
97
99
|
- Left accent bar colored by state: green (current), yellow (running), red (error), peach (interrupted), lavender (focused), teal (unseen done)
|
|
98
100
|
|
|
99
|
-
**`
|
|
100
|
-
|
|
101
|
-
- Drag-resizable separator (height persisted per session in config)
|
|
102
|
-
- Truncated working directory
|
|
103
|
-
- Agent list via `AgentListItem` sub-component (see below)
|
|
104
|
-
- Metadata section: status line with tone icon + progress, last 8 log entries
|
|
105
|
-
|
|
106
|
-
**`AgentListItem`** (inside `DetailPanel.tsx`) — single agent instance row
|
|
101
|
+
**`AgentRow`** (inside `SessionCard.tsx`) — single agent instance row
|
|
107
102
|
|
|
108
103
|
- Status icon: braille spinner (running), `◉` (waiting), `✓` (done), `✗` (error), `⚠` (interrupted)
|
|
109
104
|
- Agent name, thread name, status text
|
|
110
105
|
- Dismiss `✕` button (hover turns red), click row to focus the agent's tmux pane
|
|
111
|
-
- Flash animation on click
|
|
106
|
+
- Flash animation on click, surface0 highlight when keyboard-focused
|
|
112
107
|
|
|
113
108
|
**`HelpOverlay`** (inline in `index.tsx`) — modal overlay
|
|
114
109
|
|
|
@@ -117,9 +112,9 @@ Solid.js app rendered via OpenTUI. Connects to server over WebSocket.
|
|
|
117
112
|
|
|
118
113
|
#### TUI Utilities
|
|
119
114
|
|
|
120
|
-
- `constants.ts` — shared icons (`SPINNERS`, `UNSEEN_ICON
|
|
115
|
+
- `constants.ts` — shared icons (`SPINNERS`, `UNSEEN_ICON`), theme list, tone-to-color mapping
|
|
121
116
|
- `mux-context.ts` — tmux detection, pane refocus after startup, client TTY and session name resolution
|
|
122
|
-
- `
|
|
117
|
+
- `components/cache-bar.ts` — cache-countdown bar helpers (`cacheBar`, `cacheBarColor`, `shortModel`)
|
|
123
118
|
|
|
124
119
|
## Configuration
|
|
125
120
|
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
import { Show } from "solid-js";
|
|
1
|
+
import { createSignal, For, Show, onCleanup } from "solid-js";
|
|
2
2
|
import type { Accessor } from "solid-js";
|
|
3
|
-
import type { SessionData, Theme } from "@tt-agentboard/runtime";
|
|
4
|
-
import {
|
|
3
|
+
import type { AgentStatus, SessionData, Theme } from "@tt-agentboard/runtime";
|
|
4
|
+
import { truncate } from "@tt-agentboard/runtime";
|
|
5
|
+
import { UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
|
|
5
6
|
import { DiffStats } from "./DiffStats";
|
|
7
|
+
import { cacheBarVisual, shortModel } from "./cache-bar";
|
|
8
|
+
import { liveStatusIcon, unseenTerminalColor } from "./status-visuals";
|
|
9
|
+
|
|
10
|
+
const STATUS_TEXT: Record<AgentStatus, string> = {
|
|
11
|
+
idle: "",
|
|
12
|
+
running: "running",
|
|
13
|
+
done: "done",
|
|
14
|
+
error: "error",
|
|
15
|
+
waiting: "waiting",
|
|
16
|
+
question: "question",
|
|
17
|
+
interrupted: "stopped",
|
|
18
|
+
};
|
|
6
19
|
|
|
7
20
|
export interface SessionCardProps {
|
|
8
21
|
session: SessionData;
|
|
@@ -10,9 +23,13 @@ export interface SessionCardProps {
|
|
|
10
23
|
isFocused: boolean;
|
|
11
24
|
isCurrent: boolean;
|
|
12
25
|
spinIdx: Accessor<number>;
|
|
26
|
+
now: Accessor<number>;
|
|
13
27
|
theme: Accessor<Theme>;
|
|
14
28
|
statusColors: Accessor<Theme["status"]>;
|
|
29
|
+
focusedAgentIdx: number;
|
|
15
30
|
onSelect: () => void;
|
|
31
|
+
onDismissAgent: (agent: SessionData["agents"][number]) => void;
|
|
32
|
+
onFocusAgentPane: (agent: SessionData["agents"][number]) => void;
|
|
16
33
|
}
|
|
17
34
|
|
|
18
35
|
export function SessionCard(props: SessionCardProps) {
|
|
@@ -28,7 +45,7 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
28
45
|
|
|
29
46
|
const accentColor = () => {
|
|
30
47
|
if (props.isCurrent) return P().green;
|
|
31
|
-
if (isUnseenTerminal()) return
|
|
48
|
+
if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
|
|
32
49
|
const s = status();
|
|
33
50
|
if (s === "error") return P().red;
|
|
34
51
|
if (s === "interrupted") return P().peach;
|
|
@@ -39,24 +56,14 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
39
56
|
return "transparent";
|
|
40
57
|
};
|
|
41
58
|
|
|
42
|
-
const unseenAccentColor = () => {
|
|
43
|
-
const s = status();
|
|
44
|
-
if (s === "error") return P().red;
|
|
45
|
-
if (s === "interrupted") return P().peach;
|
|
46
|
-
return P().teal;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
59
|
const statusIcon = () => {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
if (s === "question") return "?";
|
|
54
|
-
if (isUnseenTerminal()) return UNSEEN_ICON;
|
|
55
|
-
return "";
|
|
60
|
+
const live = liveStatusIcon(status(), props.spinIdx());
|
|
61
|
+
if (live) return live;
|
|
62
|
+
return isUnseenTerminal() ? UNSEEN_ICON : "";
|
|
56
63
|
};
|
|
57
64
|
|
|
58
65
|
const statusColor = () => {
|
|
59
|
-
if (isUnseenTerminal()) return
|
|
66
|
+
if (isUnseenTerminal()) return unseenTerminalColor(status(), P());
|
|
60
67
|
return SC()[status()];
|
|
61
68
|
};
|
|
62
69
|
|
|
@@ -71,16 +78,8 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
71
78
|
return P().surface2;
|
|
72
79
|
};
|
|
73
80
|
|
|
74
|
-
const truncName = () =>
|
|
75
|
-
|
|
76
|
-
return n.length > 18 ? n.slice(0, 17) + "…" : n;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const truncBranch = () => {
|
|
80
|
-
const b = props.session.branch;
|
|
81
|
-
if (!b) return "";
|
|
82
|
-
return b.length > 30 ? b.slice(0, 29) + "…" : b;
|
|
83
|
-
};
|
|
81
|
+
const truncName = () => truncate(props.session.name, 18);
|
|
82
|
+
const truncBranch = () => (props.session.branch ? truncate(props.session.branch, 30) : "");
|
|
84
83
|
|
|
85
84
|
const hasDiff = () => {
|
|
86
85
|
const { linesAdded, linesRemoved, commitsDelta, filesChanged } = props.session;
|
|
@@ -110,6 +109,8 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
110
109
|
return "transparent";
|
|
111
110
|
};
|
|
112
111
|
|
|
112
|
+
const agents = () => props.session.agents ?? [];
|
|
113
|
+
|
|
113
114
|
return (
|
|
114
115
|
<box flexDirection="column" flexShrink={0}>
|
|
115
116
|
<box
|
|
@@ -118,17 +119,13 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
118
119
|
onMouseDown={props.onSelect}
|
|
119
120
|
paddingLeft={1}
|
|
120
121
|
>
|
|
121
|
-
{/* Left accent — space-preserving, only colored for meaningful states */}
|
|
122
122
|
<text style={{ fg: accentColor() }}>{accentColor() === "transparent" ? " " : "▌"}</text>
|
|
123
123
|
|
|
124
|
-
{/* Index */}
|
|
125
124
|
<box width={3} flexShrink={0}>
|
|
126
125
|
<text style={{ fg: indexColor() }}>{String(props.index).padStart(2)}</text>
|
|
127
126
|
</box>
|
|
128
127
|
|
|
129
|
-
{/* Content */}
|
|
130
128
|
<box flexDirection="column" flexGrow={1} paddingRight={1}>
|
|
131
|
-
{/* Row 1: name + status */}
|
|
132
129
|
<box flexDirection="row">
|
|
133
130
|
<text truncate flexGrow={1}>
|
|
134
131
|
<span
|
|
@@ -151,19 +148,16 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
151
148
|
</Show>
|
|
152
149
|
</box>
|
|
153
150
|
|
|
154
|
-
{/* Row 2: branch */}
|
|
155
151
|
<Show when={props.session.branch}>
|
|
156
152
|
<text truncate>
|
|
157
153
|
<span style={{ fg: props.isFocused ? P().pink : P().overlay0 }}>{truncBranch()}</span>
|
|
158
154
|
</text>
|
|
159
155
|
</Show>
|
|
160
156
|
|
|
161
|
-
{/* Row 3: git diff stats */}
|
|
162
157
|
<Show when={hasDiff()}>
|
|
163
158
|
<DiffStats session={props.session} palette={() => P()} />
|
|
164
159
|
</Show>
|
|
165
160
|
|
|
166
|
-
{/* Row 3: metadata summary (status + progress) */}
|
|
167
161
|
<Show when={metaSummary()}>
|
|
168
162
|
<text truncate>
|
|
169
163
|
<span style={{ fg: toneColor(metaTone(), P()), attributes: DIM }}>
|
|
@@ -171,11 +165,168 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
171
165
|
</span>
|
|
172
166
|
</text>
|
|
173
167
|
</Show>
|
|
168
|
+
|
|
169
|
+
<For each={agents()}>
|
|
170
|
+
{(agent, i) => (
|
|
171
|
+
<AgentRow
|
|
172
|
+
agent={agent}
|
|
173
|
+
palette={P}
|
|
174
|
+
statusColors={props.statusColors}
|
|
175
|
+
spinIdx={props.spinIdx}
|
|
176
|
+
now={props.now}
|
|
177
|
+
isKeyboardFocused={i() === props.focusedAgentIdx}
|
|
178
|
+
onDismiss={() => props.onDismissAgent(agent)}
|
|
179
|
+
onFocusPane={() => props.onFocusAgentPane(agent)}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
</For>
|
|
174
183
|
</box>
|
|
175
184
|
</box>
|
|
176
185
|
|
|
177
|
-
{/* Breathing room — 1 empty line between cards */}
|
|
178
186
|
<box height={1} />
|
|
179
187
|
</box>
|
|
180
188
|
);
|
|
181
189
|
}
|
|
190
|
+
|
|
191
|
+
interface AgentRowProps {
|
|
192
|
+
agent: SessionData["agents"][number];
|
|
193
|
+
palette: Accessor<Theme["palette"]>;
|
|
194
|
+
statusColors: Accessor<Theme["status"]>;
|
|
195
|
+
spinIdx: Accessor<number>;
|
|
196
|
+
now: Accessor<number>;
|
|
197
|
+
isKeyboardFocused: boolean;
|
|
198
|
+
onDismiss: () => void;
|
|
199
|
+
onFocusPane: () => void;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function AgentRow(props: AgentRowProps) {
|
|
203
|
+
const P = () => props.palette();
|
|
204
|
+
const SC = () => props.statusColors();
|
|
205
|
+
const [isDismissHover, setIsDismissHover] = createSignal(false);
|
|
206
|
+
const [isFlash, setIsFlash] = createSignal(false);
|
|
207
|
+
|
|
208
|
+
const isTerminal = () => ["done", "error", "interrupted"].includes(props.agent.status);
|
|
209
|
+
const isUnseen = () => isTerminal() && props.agent.unseen === true;
|
|
210
|
+
|
|
211
|
+
const icon = () => {
|
|
212
|
+
if (isUnseen()) return UNSEEN_ICON;
|
|
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()) || "○";
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const color = () => {
|
|
222
|
+
if (isTerminal()) {
|
|
223
|
+
if (isUnseen()) return unseenTerminalColor(props.agent.status, P());
|
|
224
|
+
if (props.agent.status === "error") return P().red;
|
|
225
|
+
if (props.agent.status === "interrupted") return P().peach;
|
|
226
|
+
return P().green;
|
|
227
|
+
}
|
|
228
|
+
return SC()[props.agent.status];
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const statusText = () => STATUS_TEXT[props.agent.status];
|
|
232
|
+
|
|
233
|
+
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
234
|
+
const triggerFlash = () => {
|
|
235
|
+
setIsFlash(true);
|
|
236
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
237
|
+
flashTimer = setTimeout(() => setIsFlash(false), 150);
|
|
238
|
+
};
|
|
239
|
+
onCleanup(() => {
|
|
240
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const bgColor = () => {
|
|
244
|
+
if (isFlash()) return P().surface1;
|
|
245
|
+
if (props.isKeyboardFocused) return P().surface0;
|
|
246
|
+
return "transparent";
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<box
|
|
251
|
+
flexDirection="column"
|
|
252
|
+
flexShrink={0}
|
|
253
|
+
backgroundColor={bgColor()}
|
|
254
|
+
onMouseDown={(event) => {
|
|
255
|
+
if (event.target?.id === "dismiss") return;
|
|
256
|
+
triggerFlash();
|
|
257
|
+
props.onFocusPane();
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<box flexDirection="row">
|
|
261
|
+
<text flexGrow={1} truncate>
|
|
262
|
+
<span style={{ fg: color() }}>{icon()}</span>
|
|
263
|
+
<span
|
|
264
|
+
style={{
|
|
265
|
+
fg: props.isKeyboardFocused ? P().text : P().subtext1,
|
|
266
|
+
attributes: props.isKeyboardFocused ? BOLD : undefined,
|
|
267
|
+
}}
|
|
268
|
+
>
|
|
269
|
+
{" "}
|
|
270
|
+
{props.agent.agent}
|
|
271
|
+
</span>
|
|
272
|
+
</text>
|
|
273
|
+
<Show when={!isUnseen()}>
|
|
274
|
+
<text flexShrink={0}>
|
|
275
|
+
<span style={{ fg: color(), attributes: DIM }}>{statusText()}</span>
|
|
276
|
+
</text>
|
|
277
|
+
</Show>
|
|
278
|
+
<text
|
|
279
|
+
flexShrink={0}
|
|
280
|
+
onMouseDown={(event) => {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
event.stopPropagation();
|
|
283
|
+
props.onDismiss();
|
|
284
|
+
}}
|
|
285
|
+
onMouseOver={() => setIsDismissHover(true)}
|
|
286
|
+
onMouseOut={() => setIsDismissHover(false)}
|
|
287
|
+
>
|
|
288
|
+
<span style={{ fg: isDismissHover() ? P().red : P().overlay0 }}>{" ✕"}</span>
|
|
289
|
+
</text>
|
|
290
|
+
</box>
|
|
291
|
+
|
|
292
|
+
<Show when={props.agent.threadName}>
|
|
293
|
+
<text truncate>
|
|
294
|
+
<span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
|
|
295
|
+
{props.agent.threadName!.replace(/\s+/g, " ").trim()}
|
|
296
|
+
</span>
|
|
297
|
+
</text>
|
|
298
|
+
</Show>
|
|
299
|
+
|
|
300
|
+
<Show when={props.agent.details}>
|
|
301
|
+
{(d) => {
|
|
302
|
+
const details = d();
|
|
303
|
+
const model = () => (details.model ? shortModel(details.model) : "");
|
|
304
|
+
const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
|
|
305
|
+
const visual = () =>
|
|
306
|
+
hasCache()
|
|
307
|
+
? cacheBarVisual(details.cacheExpiresAt!, details.cacheTtlMs!, props.now(), P())
|
|
308
|
+
: null;
|
|
309
|
+
return (
|
|
310
|
+
<Show when={model() || hasCache()}>
|
|
311
|
+
<text truncate>
|
|
312
|
+
<Show when={model()}>
|
|
313
|
+
<span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
|
|
314
|
+
</Show>
|
|
315
|
+
<Show when={visual()}>
|
|
316
|
+
{(v) => (
|
|
317
|
+
<>
|
|
318
|
+
<span style={{ fg: P().overlay0, attributes: DIM }}>
|
|
319
|
+
{model() ? " · cache " : "cache "}
|
|
320
|
+
</span>
|
|
321
|
+
<span style={{ fg: v().color }}>{v().bar}</span>
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
</Show>
|
|
325
|
+
</text>
|
|
326
|
+
</Show>
|
|
327
|
+
);
|
|
328
|
+
}}
|
|
329
|
+
</Show>
|
|
330
|
+
</box>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,22 +7,11 @@ export const SPINNERS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
|
|
|
7
7
|
export const UNSEEN_ICON = "●";
|
|
8
8
|
export const BOLD = TextAttributes.BOLD;
|
|
9
9
|
export const DIM = TextAttributes.DIM;
|
|
10
|
-
export const SPARK_BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
11
10
|
export const DIVIDER = "─".repeat(200);
|
|
12
11
|
|
|
13
12
|
export const THEME_NAMES = Object.keys(BUILTIN_THEMES);
|
|
14
|
-
export const DEFAULT_DETAIL_PANEL_HEIGHT = 10;
|
|
15
|
-
export const MIN_DETAIL_PANEL_HEIGHT = 4;
|
|
16
13
|
export { TUI_RESIZE_LOG };
|
|
17
14
|
|
|
18
|
-
export const TONE_ICONS: Record<MetadataTone, string> = {
|
|
19
|
-
neutral: "·",
|
|
20
|
-
info: "ℹ",
|
|
21
|
-
success: "✓",
|
|
22
|
-
warn: "⚠",
|
|
23
|
-
error: "✗",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
15
|
export function toneColor(tone: MetadataTone | undefined, palette: Theme["palette"]): string {
|
|
27
16
|
switch (tone) {
|
|
28
17
|
case "success":
|
|
@@ -13,12 +13,16 @@ import {
|
|
|
13
13
|
} from "solid-js";
|
|
14
14
|
import type { Accessor } from "solid-js";
|
|
15
15
|
import { createStore, reconcile } from "solid-js/store";
|
|
16
|
-
import type { MouseEvent } from "@opentui/core";
|
|
17
16
|
|
|
18
17
|
import { ensureServer, SERVER_PORT, SERVER_HOST, resolveTheme } from "@tt-agentboard/runtime";
|
|
19
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
ServerMessage,
|
|
20
|
+
SessionData,
|
|
21
|
+
ClientCommand,
|
|
22
|
+
ReorderDelta,
|
|
23
|
+
Theme,
|
|
24
|
+
} from "@tt-agentboard/runtime";
|
|
20
25
|
import { SessionCard } from "./components/SessionCard";
|
|
21
|
-
import { DetailPanel } from "./components/DetailPanel";
|
|
22
26
|
import { StatusBar } from "./components/StatusBar";
|
|
23
27
|
import { computeSessionStatusCounts } from "./session-status";
|
|
24
28
|
import {
|
|
@@ -27,19 +31,7 @@ import {
|
|
|
27
31
|
getClientTty,
|
|
28
32
|
getLocalSessionName,
|
|
29
33
|
} from "./mux-context";
|
|
30
|
-
import {
|
|
31
|
-
clampDetailPanelHeight,
|
|
32
|
-
getStoredDetailPanelHeight,
|
|
33
|
-
persistDetailPanelHeight,
|
|
34
|
-
} from "./detail-panel-height";
|
|
35
|
-
import {
|
|
36
|
-
SPINNERS,
|
|
37
|
-
BOLD,
|
|
38
|
-
DIM,
|
|
39
|
-
DEFAULT_DETAIL_PANEL_HEIGHT,
|
|
40
|
-
DIVIDER,
|
|
41
|
-
logResizeDebug,
|
|
42
|
-
} from "./constants";
|
|
34
|
+
import { SPINNERS, BOLD, DIM, DIVIDER, logResizeDebug } from "./constants";
|
|
43
35
|
|
|
44
36
|
const muxCtx = detectMuxContext();
|
|
45
37
|
|
|
@@ -92,14 +84,9 @@ function App() {
|
|
|
92
84
|
const [sessions, setSessions] = createStore<SessionData[]>([]);
|
|
93
85
|
const [focusedSession, setFocusedSession] = createSignal<string | null>(null);
|
|
94
86
|
const [currentSession, setCurrentSession] = createSignal<string | null>(null);
|
|
95
|
-
const [mySession, setMySession] = createSignal<string | null>(null);
|
|
96
87
|
const [connected, setConnected] = createSignal(false);
|
|
97
88
|
const [spinIdx, setSpinIdx] = createSignal(0);
|
|
98
|
-
const [detailPanelHeight, setDetailPanelHeight] = createSignal(DEFAULT_DETAIL_PANEL_HEIGHT);
|
|
99
89
|
const [preferredEditor, setPreferredEditor] = createSignal("code");
|
|
100
|
-
const [isDetailResizeHover, setIsDetailResizeHover] = createSignal(false);
|
|
101
|
-
const [isDetailResizing, setIsDetailResizing] = createSignal(false);
|
|
102
|
-
const detailPanelSessionName = createMemo(() => focusedSession() ?? mySession());
|
|
103
90
|
|
|
104
91
|
// --- Panel focus: sessions list vs agent detail ---
|
|
105
92
|
type PanelFocus = "sessions" | "agents";
|
|
@@ -123,8 +110,6 @@ function App() {
|
|
|
123
110
|
const [clientTty, setClientTty] = createSignal(getClientTty(muxCtx));
|
|
124
111
|
let ws: WebSocket | null = null;
|
|
125
112
|
let startupFocusSynced = false;
|
|
126
|
-
let detailResizeStartY = 0;
|
|
127
|
-
let detailResizeStartHeight = DEFAULT_DETAIL_PANEL_HEIGHT;
|
|
128
113
|
const startupSessionName = getLocalSessionName(muxCtx);
|
|
129
114
|
|
|
130
115
|
const focusedData = createMemo(() => sessions.find((s) => s.name === focusedSession()) ?? null);
|
|
@@ -240,76 +225,6 @@ function App() {
|
|
|
240
225
|
);
|
|
241
226
|
}
|
|
242
227
|
|
|
243
|
-
function beginDetailResize(event: MouseEvent) {
|
|
244
|
-
if (TUI_DEBUG)
|
|
245
|
-
logResizeDebug("beginDetailResize", {
|
|
246
|
-
button: event.button,
|
|
247
|
-
x: event.x,
|
|
248
|
-
y: event.y,
|
|
249
|
-
currentHeight: detailPanelHeight(),
|
|
250
|
-
session: detailPanelSessionName(),
|
|
251
|
-
target: event.target?.id ?? null,
|
|
252
|
-
});
|
|
253
|
-
if (event.button !== 0) return;
|
|
254
|
-
(renderer as any).setCapturedRenderable?.(event.target ?? undefined);
|
|
255
|
-
detailResizeStartY = event.y;
|
|
256
|
-
detailResizeStartHeight = detailPanelHeight();
|
|
257
|
-
setIsDetailResizing(true);
|
|
258
|
-
event.stopPropagation();
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function handleDetailResizeDrag(event: MouseEvent) {
|
|
262
|
-
if (TUI_DEBUG)
|
|
263
|
-
logResizeDebug("handleDetailResizeDrag", {
|
|
264
|
-
x: event.x,
|
|
265
|
-
y: event.y,
|
|
266
|
-
isResizing: isDetailResizing(),
|
|
267
|
-
startY: detailResizeStartY,
|
|
268
|
-
startHeight: detailResizeStartHeight,
|
|
269
|
-
currentHeight: detailPanelHeight(),
|
|
270
|
-
session: detailPanelSessionName(),
|
|
271
|
-
});
|
|
272
|
-
if (!isDetailResizing()) return;
|
|
273
|
-
const delta = detailResizeStartY - event.y;
|
|
274
|
-
const nextHeight = clampDetailPanelHeight(detailResizeStartHeight + delta);
|
|
275
|
-
setDetailPanelHeight(nextHeight);
|
|
276
|
-
if (TUI_DEBUG)
|
|
277
|
-
logResizeDebug("handleDetailResizeDrag:applied", {
|
|
278
|
-
delta,
|
|
279
|
-
nextHeight,
|
|
280
|
-
session: detailPanelSessionName(),
|
|
281
|
-
});
|
|
282
|
-
event.stopPropagation();
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function endDetailResize(event?: MouseEvent) {
|
|
286
|
-
if (TUI_DEBUG)
|
|
287
|
-
logResizeDebug("endDetailResize", {
|
|
288
|
-
x: event?.x,
|
|
289
|
-
y: event?.y,
|
|
290
|
-
isResizing: isDetailResizing(),
|
|
291
|
-
currentHeight: detailPanelHeight(),
|
|
292
|
-
session: detailPanelSessionName(),
|
|
293
|
-
target: event?.target?.id ?? null,
|
|
294
|
-
});
|
|
295
|
-
if (!isDetailResizing()) return;
|
|
296
|
-
(renderer as any).setCapturedRenderable?.(undefined);
|
|
297
|
-
setIsDetailResizing(false);
|
|
298
|
-
setIsDetailResizeHover(false);
|
|
299
|
-
|
|
300
|
-
const sessionName = detailPanelSessionName();
|
|
301
|
-
if (sessionName) {
|
|
302
|
-
persistDetailPanelHeight(sessionName, detailPanelHeight());
|
|
303
|
-
if (TUI_DEBUG)
|
|
304
|
-
logResizeDebug("endDetailResize:persisted", {
|
|
305
|
-
session: sessionName,
|
|
306
|
-
height: detailPanelHeight(),
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
event?.stopPropagation();
|
|
311
|
-
}
|
|
312
|
-
|
|
313
228
|
function createNewSession() {
|
|
314
229
|
if (muxCtx.type !== "tmux") {
|
|
315
230
|
send({ type: "new-session" });
|
|
@@ -418,7 +333,6 @@ function App() {
|
|
|
418
333
|
setFocusedSession(msg.focusedSession);
|
|
419
334
|
setCurrentSession(msg.currentSession);
|
|
420
335
|
} else if (msg.type === "your-session") {
|
|
421
|
-
setMySession(msg.name);
|
|
422
336
|
if (msg.clientTty) setClientTty(msg.clientTty);
|
|
423
337
|
|
|
424
338
|
if (!startupFocusSynced && sessions.some((session) => session.name === msg.name)) {
|
|
@@ -461,25 +375,16 @@ function App() {
|
|
|
461
375
|
onCleanup(() => clearInterval(interval));
|
|
462
376
|
});
|
|
463
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
|
+
);
|
|
464
384
|
createEffect(() => {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (TUI_DEBUG)
|
|
469
|
-
logResizeDebug("loadStoredDetailPanelHeight", {
|
|
470
|
-
session: sessionName,
|
|
471
|
-
storedHeight,
|
|
472
|
-
});
|
|
473
|
-
setDetailPanelHeight(storedHeight);
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
createEffect(() => {
|
|
477
|
-
if (TUI_DEBUG)
|
|
478
|
-
logResizeDebug("detailPanelHeight:changed", {
|
|
479
|
-
height: detailPanelHeight(),
|
|
480
|
-
session: detailPanelSessionName(),
|
|
481
|
-
isResizing: isDetailResizing(),
|
|
482
|
-
});
|
|
385
|
+
if (!hasActiveCache()) return;
|
|
386
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
387
|
+
onCleanup(() => clearInterval(id));
|
|
483
388
|
});
|
|
484
389
|
|
|
485
390
|
useKeyboard((key) => {
|
|
@@ -506,11 +411,12 @@ function App() {
|
|
|
506
411
|
}
|
|
507
412
|
|
|
508
413
|
// --- Normal mode keybindings ---
|
|
509
|
-
// Alt+Up
|
|
414
|
+
// Alt+Up/Down → reorder session ±1. Alt+Shift+Up/Down → jump to top/bottom.
|
|
510
415
|
if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
|
|
511
416
|
const focused = focusedSession();
|
|
512
417
|
if (focused) {
|
|
513
|
-
const
|
|
418
|
+
const up = key.name === "up";
|
|
419
|
+
const delta: ReorderDelta = key.shift ? (up ? "top" : "bottom") : up ? "up" : "down";
|
|
514
420
|
send({ type: "reorder-session", name: focused, delta });
|
|
515
421
|
}
|
|
516
422
|
return;
|
|
@@ -648,60 +554,38 @@ function App() {
|
|
|
648
554
|
isFocused={isFocused(session.name)}
|
|
649
555
|
isCurrent={session.name === currentSession()}
|
|
650
556
|
spinIdx={spinIdx}
|
|
557
|
+
now={now}
|
|
651
558
|
theme={theme}
|
|
652
559
|
statusColors={S}
|
|
560
|
+
focusedAgentIdx={
|
|
561
|
+
isFocused(session.name) && panelFocus() === "agents" ? focusedAgentIdx() : -1
|
|
562
|
+
}
|
|
653
563
|
onSelect={() => {
|
|
654
564
|
setFocusedSession(session.name);
|
|
655
565
|
send({ type: "focus-session", name: session.name });
|
|
656
566
|
switchToSession(session.name);
|
|
657
567
|
}}
|
|
658
|
-
/>
|
|
659
|
-
)}
|
|
660
|
-
</For>
|
|
661
|
-
</scrollbox>
|
|
662
|
-
|
|
663
|
-
{/* Detail panel — focused session info, draggable height */}
|
|
664
|
-
<Show when={focusedData()}>
|
|
665
|
-
{(data) => (
|
|
666
|
-
<scrollbox height={detailPanelHeight()} maxHeight={detailPanelHeight()} flexShrink={0}>
|
|
667
|
-
<DetailPanel
|
|
668
|
-
session={data()}
|
|
669
|
-
theme={theme}
|
|
670
|
-
statusColors={S}
|
|
671
|
-
spinIdx={spinIdx}
|
|
672
|
-
focusedAgentIdx={panelFocus() === "agents" ? focusedAgentIdx() : -1}
|
|
673
568
|
onDismissAgent={(agent) => {
|
|
674
569
|
send({
|
|
675
570
|
type: "dismiss-agent",
|
|
676
|
-
session:
|
|
571
|
+
session: session.name,
|
|
677
572
|
agent: agent.agent,
|
|
678
573
|
threadId: agent.threadId,
|
|
679
574
|
});
|
|
680
575
|
}}
|
|
681
576
|
onFocusAgentPane={(agent) => {
|
|
682
|
-
if (TUI_DEBUG)
|
|
683
|
-
appendFileSync(
|
|
684
|
-
"/tmp/agentboard-tui-agent-click.log",
|
|
685
|
-
`[${new Date().toISOString()}] sending focus-agent-pane session=${data().name} agent=${agent.agent} threadId=${agent.threadId} threadName=${agent.threadName}\n`,
|
|
686
|
-
);
|
|
687
577
|
send({
|
|
688
578
|
type: "focus-agent-pane",
|
|
689
|
-
session:
|
|
579
|
+
session: session.name,
|
|
690
580
|
agent: agent.agent,
|
|
691
581
|
threadId: agent.threadId,
|
|
692
582
|
threadName: agent.threadName,
|
|
693
583
|
});
|
|
694
584
|
}}
|
|
695
|
-
isResizeHover={isDetailResizeHover()}
|
|
696
|
-
isResizing={isDetailResizing()}
|
|
697
|
-
onResizeStart={beginDetailResize}
|
|
698
|
-
onResizeDrag={handleDetailResizeDrag}
|
|
699
|
-
onResizeEnd={endDetailResize}
|
|
700
|
-
onResizeHoverChange={setIsDetailResizeHover}
|
|
701
585
|
/>
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
</
|
|
586
|
+
)}
|
|
587
|
+
</For>
|
|
588
|
+
</scrollbox>
|
|
705
589
|
|
|
706
590
|
{/* Footer */}
|
|
707
591
|
<box flexDirection="column" paddingLeft={1} paddingBottom={1} paddingTop={0} flexShrink={0}>
|
|
@@ -812,6 +696,7 @@ const HELP_KEYS: [string, string][] = [
|
|
|
812
696
|
["→/l", "Agents panel"],
|
|
813
697
|
["←/h/Esc", "Back to sessions"],
|
|
814
698
|
["Alt+↑↓", "Reorder sessions"],
|
|
699
|
+
["Alt+Shift+↑↓", "Move to top/bottom"],
|
|
815
700
|
["q", "Quit"],
|
|
816
701
|
];
|
|
817
702
|
|
|
@@ -16,8 +16,6 @@ export interface AgentboardConfig {
|
|
|
16
16
|
sidebarPosition?: "left" | "right";
|
|
17
17
|
/** Tmux prefix key for sidebar toggle (default "s") */
|
|
18
18
|
keybinding?: string;
|
|
19
|
-
/** Persisted detail panel heights keyed by mux session name */
|
|
20
|
-
detailPanelHeights?: Record<string, number>;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
const DEFAULTS: AgentboardConfig = {};
|
|
@@ -229,9 +229,19 @@ export function startServer(
|
|
|
229
229
|
watcherAgents: AgentEvent[],
|
|
230
230
|
): AgentEvent[] {
|
|
231
231
|
const paneAgents = paneAgentsBySession.get(sessionName);
|
|
232
|
-
if (!paneAgents || paneAgents.size === 0) return watcherAgents;
|
|
233
232
|
|
|
234
|
-
|
|
233
|
+
// Drop agents whose pane has closed. Tracker only prunes terminals on a
|
|
234
|
+
// timeout, so non-terminal agents (waiting/running/question) would otherwise
|
|
235
|
+
// linger forever after their tmux pane is killed.
|
|
236
|
+
const livePaneAgents = watcherAgents.filter((a) => {
|
|
237
|
+
if (!a.paneId) return true;
|
|
238
|
+
if (TERMINAL_STATUSES.has(a.status)) return true;
|
|
239
|
+
return paneAgents?.has(instanceKey(a.agent, a.threadId)) ?? false;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (!paneAgents || paneAgents.size === 0) return livePaneAgents;
|
|
243
|
+
|
|
244
|
+
const result = [...livePaneAgents];
|
|
235
245
|
const trackedByKey = new Map(result.map((a, i) => [instanceKey(a.agent, a.threadId), i]));
|
|
236
246
|
|
|
237
247
|
for (const [, presence] of paneAgents) {
|
|
@@ -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,14 +61,20 @@ export class SessionOrder {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/** Move a session by
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
if (delta === "top") {
|
|
69
|
+
this.order = [name, ...this.order.filter((n) => n !== name)];
|
|
70
|
+
} else if (delta === "bottom") {
|
|
71
|
+
this.order = [...this.order.filter((n) => n !== name), name];
|
|
72
|
+
} else {
|
|
73
|
+
const step = delta === "up" ? -1 : 1;
|
|
74
|
+
const newIdx = idx + step;
|
|
75
|
+
if (newIdx < 0 || newIdx >= this.order.length) return;
|
|
76
|
+
[this.order[idx], this.order[newIdx]] = [this.order[newIdx]!, this.order[idx]!];
|
|
77
|
+
}
|
|
70
78
|
this.save();
|
|
71
79
|
}
|
|
72
80
|
|
|
@@ -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,414 +0,0 @@
|
|
|
1
|
-
import { createSignal, For, Show, onCleanup } from "solid-js";
|
|
2
|
-
import type { Accessor } from "solid-js";
|
|
3
|
-
import type { MouseEvent } from "@opentui/core";
|
|
4
|
-
import type { SessionData, Theme } from "@tt-agentboard/runtime";
|
|
5
|
-
import { TUI_AGENT_CLICK_LOG } from "@tt-agentboard/runtime";
|
|
6
|
-
import { appendFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
8
|
-
SPINNERS,
|
|
9
|
-
UNSEEN_ICON,
|
|
10
|
-
BOLD,
|
|
11
|
-
DIM,
|
|
12
|
-
DIVIDER,
|
|
13
|
-
SPARK_BLOCKS,
|
|
14
|
-
TONE_ICONS,
|
|
15
|
-
toneColor,
|
|
16
|
-
logResizeDebug,
|
|
17
|
-
} from "../constants";
|
|
18
|
-
|
|
19
|
-
// --- Sparkline ---
|
|
20
|
-
|
|
21
|
-
export function buildSparkline(
|
|
22
|
-
timestamps: number[],
|
|
23
|
-
width: number,
|
|
24
|
-
windowMs: number = 30 * 60 * 1000,
|
|
25
|
-
): string {
|
|
26
|
-
if (timestamps.length === 0 || width <= 0) return "";
|
|
27
|
-
const now = Date.now();
|
|
28
|
-
const start = now - windowMs;
|
|
29
|
-
const bucketSize = windowMs / width;
|
|
30
|
-
const buckets = Array.from({ length: width }, () => 0);
|
|
31
|
-
|
|
32
|
-
for (const ts of timestamps) {
|
|
33
|
-
if (ts < start) continue;
|
|
34
|
-
const idx = Math.min(width - 1, Math.floor((ts - start) / bucketSize));
|
|
35
|
-
buckets[idx]++;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const max = Math.max(...buckets, 1);
|
|
39
|
-
return buckets
|
|
40
|
-
.map((count: number) => {
|
|
41
|
-
const level = Math.round((count / max) * (SPARK_BLOCKS.length - 1));
|
|
42
|
-
return SPARK_BLOCKS[level];
|
|
43
|
-
})
|
|
44
|
-
.join("");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// --- Model / cache display helpers ---
|
|
48
|
-
|
|
49
|
-
function shortModel(model: string): string {
|
|
50
|
-
if (!model) return "";
|
|
51
|
-
const stripped = model.replace(/^claude-/, "").replace(/\[1m\]$/i, "");
|
|
52
|
-
return stripped;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const CACHE_BAR_WIDTH = 10;
|
|
56
|
-
const CACHE_BAR_FILLED = "▰";
|
|
57
|
-
const CACHE_BAR_EMPTY = "▱";
|
|
58
|
-
|
|
59
|
-
/** Render a drain-down bar: full = freshly cached, empty = expired. */
|
|
60
|
-
function cacheBar(expiresAt: number, ttlMs: number, now: number): string {
|
|
61
|
-
const remaining = expiresAt - now;
|
|
62
|
-
if (remaining <= 0 || ttlMs <= 0) return CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH);
|
|
63
|
-
const fraction = Math.max(0, Math.min(1, remaining / ttlMs));
|
|
64
|
-
const filled = Math.round(fraction * CACHE_BAR_WIDTH);
|
|
65
|
-
return CACHE_BAR_FILLED.repeat(filled) + CACHE_BAR_EMPTY.repeat(CACHE_BAR_WIDTH - filled);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// --- Detail Panel ---
|
|
69
|
-
|
|
70
|
-
export interface DetailPanelProps {
|
|
71
|
-
session: SessionData;
|
|
72
|
-
theme: Accessor<Theme>;
|
|
73
|
-
statusColors: Accessor<Theme["status"]>;
|
|
74
|
-
spinIdx: Accessor<number>;
|
|
75
|
-
focusedAgentIdx: number;
|
|
76
|
-
onDismissAgent: (agent: SessionData["agents"][number]) => void;
|
|
77
|
-
onFocusAgentPane: (agent: SessionData["agents"][number]) => void;
|
|
78
|
-
isResizeHover: boolean;
|
|
79
|
-
isResizing: boolean;
|
|
80
|
-
onResizeStart: (event: MouseEvent) => void;
|
|
81
|
-
onResizeDrag: (event: MouseEvent) => void;
|
|
82
|
-
onResizeEnd: (event?: MouseEvent) => void;
|
|
83
|
-
onResizeHoverChange: (hovered: boolean) => void;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function DetailPanel(props: DetailPanelProps) {
|
|
87
|
-
const P = () => props.theme().palette;
|
|
88
|
-
|
|
89
|
-
const agents = () => props.session.agents ?? [];
|
|
90
|
-
const hasAgents = () => agents().length > 0;
|
|
91
|
-
const meta = () => props.session.metadata;
|
|
92
|
-
const hasMeta = () => !!meta();
|
|
93
|
-
const visibleLogs = () => {
|
|
94
|
-
const m = meta();
|
|
95
|
-
if (!m || m.logs.length === 0) return [];
|
|
96
|
-
return m.logs.slice(-8);
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const truncDir = () => {
|
|
100
|
-
const d = props.session.dir;
|
|
101
|
-
if (!d) return "";
|
|
102
|
-
const home = process.env.HOME ?? "";
|
|
103
|
-
const short = home && d.startsWith(home) ? "~" + d.slice(home.length) : d;
|
|
104
|
-
return short.length > 24 ? "…" + short.slice(short.length - 23) : short;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
return (
|
|
108
|
-
<box flexDirection="column" flexShrink={0} paddingLeft={1}>
|
|
109
|
-
<box height={1}>
|
|
110
|
-
<text
|
|
111
|
-
selectable={false}
|
|
112
|
-
onMouseDown={(event) => {
|
|
113
|
-
logResizeDebug("separator:onMouseDown", {
|
|
114
|
-
x: event.x,
|
|
115
|
-
y: event.y,
|
|
116
|
-
button: event.button,
|
|
117
|
-
session: props.session.name,
|
|
118
|
-
});
|
|
119
|
-
event.preventDefault();
|
|
120
|
-
props.onResizeStart(event);
|
|
121
|
-
}}
|
|
122
|
-
onMouseDrag={(event) => {
|
|
123
|
-
logResizeDebug("separator:onMouseDrag", {
|
|
124
|
-
x: event.x,
|
|
125
|
-
y: event.y,
|
|
126
|
-
button: event.button,
|
|
127
|
-
session: props.session.name,
|
|
128
|
-
});
|
|
129
|
-
event.preventDefault();
|
|
130
|
-
props.onResizeDrag(event);
|
|
131
|
-
}}
|
|
132
|
-
onMouseDragEnd={(event) => {
|
|
133
|
-
logResizeDebug("separator:onMouseDragEnd", {
|
|
134
|
-
x: event.x,
|
|
135
|
-
y: event.y,
|
|
136
|
-
button: event.button,
|
|
137
|
-
session: props.session.name,
|
|
138
|
-
});
|
|
139
|
-
event.preventDefault();
|
|
140
|
-
props.onResizeEnd(event);
|
|
141
|
-
}}
|
|
142
|
-
onMouseUp={(event) => {
|
|
143
|
-
logResizeDebug("separator:onMouseUp", {
|
|
144
|
-
x: event.x,
|
|
145
|
-
y: event.y,
|
|
146
|
-
button: event.button,
|
|
147
|
-
session: props.session.name,
|
|
148
|
-
});
|
|
149
|
-
event.preventDefault();
|
|
150
|
-
props.onResizeEnd(event);
|
|
151
|
-
}}
|
|
152
|
-
onMouseOver={() => props.onResizeHoverChange(true)}
|
|
153
|
-
onMouseOut={() => {
|
|
154
|
-
if (!props.isResizing) props.onResizeHoverChange(false);
|
|
155
|
-
}}
|
|
156
|
-
style={{
|
|
157
|
-
fg: props.isResizing ? P().blue : props.isResizeHover ? P().overlay1 : P().surface2,
|
|
158
|
-
}}
|
|
159
|
-
>
|
|
160
|
-
{DIVIDER}
|
|
161
|
-
</text>
|
|
162
|
-
</box>
|
|
163
|
-
|
|
164
|
-
{/* Directory */}
|
|
165
|
-
<text truncate>
|
|
166
|
-
<span style={{ fg: P().overlay0, attributes: DIM }}>{truncDir()}</span>
|
|
167
|
-
</text>
|
|
168
|
-
|
|
169
|
-
{/* Agent instances */}
|
|
170
|
-
<Show when={hasAgents()}>
|
|
171
|
-
<For each={agents()}>
|
|
172
|
-
{(agent, i) => (
|
|
173
|
-
<AgentListItem
|
|
174
|
-
agent={agent}
|
|
175
|
-
palette={P}
|
|
176
|
-
statusColors={props.statusColors}
|
|
177
|
-
spinIdx={props.spinIdx}
|
|
178
|
-
isKeyboardFocused={i() === props.focusedAgentIdx}
|
|
179
|
-
onDismiss={() => props.onDismissAgent(agent)}
|
|
180
|
-
onFocusPane={() => props.onFocusAgentPane(agent)}
|
|
181
|
-
/>
|
|
182
|
-
)}
|
|
183
|
-
</For>
|
|
184
|
-
</Show>
|
|
185
|
-
|
|
186
|
-
{/* Metadata: status, progress, logs */}
|
|
187
|
-
<Show when={hasMeta()}>
|
|
188
|
-
{(_) => {
|
|
189
|
-
const m = meta()!;
|
|
190
|
-
const progressText = () => {
|
|
191
|
-
const p = m.progress;
|
|
192
|
-
if (!p) return "";
|
|
193
|
-
if (p.current != null && p.total != null) return `${p.current}/${p.total}`;
|
|
194
|
-
if (p.percent != null) return `${Math.round(p.percent * 100)}%`;
|
|
195
|
-
return "";
|
|
196
|
-
};
|
|
197
|
-
return (
|
|
198
|
-
<box flexDirection="column">
|
|
199
|
-
<box height={1} />
|
|
200
|
-
|
|
201
|
-
{/* Status + progress on one line */}
|
|
202
|
-
<Show when={m.status || m.progress}>
|
|
203
|
-
<box flexDirection="row" paddingRight={1}>
|
|
204
|
-
<Show when={m.status}>
|
|
205
|
-
<text truncate flexGrow={1}>
|
|
206
|
-
<span style={{ fg: toneColor(m.status!.tone, P()) }}>
|
|
207
|
-
{TONE_ICONS[m.status!.tone ?? "neutral"]} {m.status!.text}
|
|
208
|
-
</span>
|
|
209
|
-
</text>
|
|
210
|
-
</Show>
|
|
211
|
-
<Show when={m.progress}>
|
|
212
|
-
<text flexShrink={0}>
|
|
213
|
-
<span style={{ fg: P().sky }}>
|
|
214
|
-
{m.status ? " · " : ""}
|
|
215
|
-
{progressText()}
|
|
216
|
-
{m.progress!.label ? ` ${m.progress!.label}` : ""}
|
|
217
|
-
</span>
|
|
218
|
-
</text>
|
|
219
|
-
</Show>
|
|
220
|
-
</box>
|
|
221
|
-
</Show>
|
|
222
|
-
|
|
223
|
-
{/* Log entries */}
|
|
224
|
-
<Show when={visibleLogs().length > 0}>
|
|
225
|
-
<For each={visibleLogs()}>
|
|
226
|
-
{(entry) => (
|
|
227
|
-
<text truncate>
|
|
228
|
-
<span style={{ fg: toneColor(entry.tone, P()), attributes: DIM }}>
|
|
229
|
-
{TONE_ICONS[entry.tone ?? "neutral"]}
|
|
230
|
-
</span>
|
|
231
|
-
<Show when={entry.source}>
|
|
232
|
-
<span
|
|
233
|
-
style={{ fg: P().surface2, attributes: DIM }}
|
|
234
|
-
>{` [${entry.source}]`}</span>
|
|
235
|
-
</Show>
|
|
236
|
-
<span style={{ fg: P().overlay0 }}> {entry.message}</span>
|
|
237
|
-
</text>
|
|
238
|
-
)}
|
|
239
|
-
</For>
|
|
240
|
-
</Show>
|
|
241
|
-
</box>
|
|
242
|
-
);
|
|
243
|
-
}}
|
|
244
|
-
</Show>
|
|
245
|
-
</box>
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// --- Agent List Item ---
|
|
250
|
-
|
|
251
|
-
interface AgentListItemProps {
|
|
252
|
-
agent: SessionData["agents"][number];
|
|
253
|
-
palette: Accessor<Theme["palette"]>;
|
|
254
|
-
statusColors: Accessor<Theme["status"]>;
|
|
255
|
-
spinIdx: Accessor<number>;
|
|
256
|
-
isKeyboardFocused: boolean;
|
|
257
|
-
onDismiss: () => void;
|
|
258
|
-
onFocusPane: () => void;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function AgentListItem(props: AgentListItemProps) {
|
|
262
|
-
const P = () => props.palette();
|
|
263
|
-
const SC = () => props.statusColors();
|
|
264
|
-
const [isDismissHover, setIsDismissHover] = createSignal(false);
|
|
265
|
-
const [isFlash, setIsFlash] = createSignal(false);
|
|
266
|
-
const [now, setNow] = createSignal(Date.now());
|
|
267
|
-
// Tick every second while any details.cacheExpiresAt is in the future
|
|
268
|
-
// (cheap; only runs while component is mounted)
|
|
269
|
-
const ticker = setInterval(() => setNow(Date.now()), 1000);
|
|
270
|
-
onCleanup(() => clearInterval(ticker));
|
|
271
|
-
|
|
272
|
-
const isTerminal = () => ["done", "error", "interrupted"].includes(props.agent.status);
|
|
273
|
-
const isUnseen = () => isTerminal() && props.agent.unseen === true;
|
|
274
|
-
|
|
275
|
-
const icon = () => {
|
|
276
|
-
if (isUnseen()) return UNSEEN_ICON;
|
|
277
|
-
if (isTerminal())
|
|
278
|
-
return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
|
|
279
|
-
if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
|
|
280
|
-
if (props.agent.status === "waiting") return "◉";
|
|
281
|
-
if (props.agent.status === "question") return "?";
|
|
282
|
-
return "○";
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const color = () => {
|
|
286
|
-
if (isTerminal()) {
|
|
287
|
-
if (props.agent.status === "error") return P().red;
|
|
288
|
-
if (props.agent.status === "interrupted") return P().peach;
|
|
289
|
-
return isUnseen() ? P().teal : P().green;
|
|
290
|
-
}
|
|
291
|
-
return SC()[props.agent.status];
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const statusText = () => {
|
|
295
|
-
if (props.agent.status === "running") return "running";
|
|
296
|
-
if (props.agent.status === "done") return "done";
|
|
297
|
-
if (props.agent.status === "error") return "error";
|
|
298
|
-
if (props.agent.status === "interrupted") return "stopped";
|
|
299
|
-
if (props.agent.status === "waiting") return "waiting";
|
|
300
|
-
if (props.agent.status === "question") return "question";
|
|
301
|
-
return "";
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
const triggerFlash = () => {
|
|
305
|
-
setIsFlash(true);
|
|
306
|
-
setTimeout(() => setIsFlash(false), 150);
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const bgColor = () => {
|
|
310
|
-
if (isFlash()) return P().surface1;
|
|
311
|
-
if (props.isKeyboardFocused) return P().surface0;
|
|
312
|
-
return "transparent";
|
|
313
|
-
};
|
|
314
|
-
|
|
315
|
-
return (
|
|
316
|
-
<box
|
|
317
|
-
flexDirection="column"
|
|
318
|
-
flexShrink={0}
|
|
319
|
-
onMouseDown={(event) => {
|
|
320
|
-
// Don't trigger focus if clicking the dismiss button
|
|
321
|
-
if (event.target?.id === "dismiss") return;
|
|
322
|
-
appendFileSync(
|
|
323
|
-
TUI_AGENT_CLICK_LOG,
|
|
324
|
-
`[${new Date().toISOString()}] clicked agent=${props.agent.agent} thread=${props.agent.threadName ?? "?"}\n`,
|
|
325
|
-
);
|
|
326
|
-
triggerFlash();
|
|
327
|
-
props.onFocusPane();
|
|
328
|
-
}}
|
|
329
|
-
>
|
|
330
|
-
<box height={1} />
|
|
331
|
-
<box flexDirection="row" backgroundColor={bgColor()} paddingLeft={1}>
|
|
332
|
-
{/* Content column — name row + thread name row */}
|
|
333
|
-
<box flexDirection="column" flexGrow={1} paddingRight={1}>
|
|
334
|
-
{/* Row 1: icon + agent name + status + dismiss */}
|
|
335
|
-
<box flexDirection="row">
|
|
336
|
-
<text flexGrow={1} truncate>
|
|
337
|
-
<span style={{ fg: color() }}>{icon()}</span>
|
|
338
|
-
<span
|
|
339
|
-
style={{
|
|
340
|
-
fg: props.isKeyboardFocused ? P().text : P().subtext1,
|
|
341
|
-
attributes: props.isKeyboardFocused ? BOLD : undefined,
|
|
342
|
-
}}
|
|
343
|
-
>
|
|
344
|
-
{" "}
|
|
345
|
-
{props.agent.agent}
|
|
346
|
-
</span>
|
|
347
|
-
</text>
|
|
348
|
-
<Show when={!isTerminal() || !isUnseen()}>
|
|
349
|
-
<text flexShrink={0}>
|
|
350
|
-
<span style={{ fg: color(), attributes: DIM }}>{statusText()}</span>
|
|
351
|
-
</text>
|
|
352
|
-
</Show>
|
|
353
|
-
<text
|
|
354
|
-
flexShrink={0}
|
|
355
|
-
onMouseDown={(event) => {
|
|
356
|
-
event.preventDefault();
|
|
357
|
-
event.stopPropagation();
|
|
358
|
-
props.onDismiss();
|
|
359
|
-
}}
|
|
360
|
-
onMouseOver={() => setIsDismissHover(true)}
|
|
361
|
-
onMouseOut={() => setIsDismissHover(false)}
|
|
362
|
-
>
|
|
363
|
-
<span style={{ fg: isDismissHover() ? P().red : P().overlay0 }}>{" ✕"}</span>
|
|
364
|
-
</text>
|
|
365
|
-
</box>
|
|
366
|
-
|
|
367
|
-
{/* Row 2: thread name */}
|
|
368
|
-
<Show when={props.agent.threadName}>
|
|
369
|
-
<text truncate>
|
|
370
|
-
<span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
|
|
371
|
-
{props.agent.threadName}
|
|
372
|
-
</span>
|
|
373
|
-
</text>
|
|
374
|
-
</Show>
|
|
375
|
-
|
|
376
|
-
{/* Row 3: model + cache-remaining progress bar */}
|
|
377
|
-
<Show when={props.agent.details}>
|
|
378
|
-
{(d) => {
|
|
379
|
-
const details = d();
|
|
380
|
-
const model = () => (details.model ? shortModel(details.model) : "");
|
|
381
|
-
const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
|
|
382
|
-
const bar = () =>
|
|
383
|
-
hasCache() ? cacheBar(details.cacheExpiresAt!, details.cacheTtlMs!, now()) : "";
|
|
384
|
-
const barColor = () => {
|
|
385
|
-
if (!hasCache()) return P().overlay0;
|
|
386
|
-
const remaining = details.cacheExpiresAt! - now();
|
|
387
|
-
if (remaining <= 0) return P().overlay0;
|
|
388
|
-
const fraction = remaining / details.cacheTtlMs!;
|
|
389
|
-
if (fraction > 0.5) return P().green;
|
|
390
|
-
if (fraction > 0.2) return P().yellow;
|
|
391
|
-
return P().peach;
|
|
392
|
-
};
|
|
393
|
-
return (
|
|
394
|
-
<Show when={model() || hasCache()}>
|
|
395
|
-
<text truncate>
|
|
396
|
-
<Show when={model()}>
|
|
397
|
-
<span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
|
|
398
|
-
</Show>
|
|
399
|
-
<Show when={hasCache()}>
|
|
400
|
-
<span style={{ fg: P().overlay0, attributes: DIM }}>
|
|
401
|
-
{model() ? " · cache " : "cache "}
|
|
402
|
-
</span>
|
|
403
|
-
<span style={{ fg: barColor() }}>{bar()}</span>
|
|
404
|
-
</Show>
|
|
405
|
-
</text>
|
|
406
|
-
</Show>
|
|
407
|
-
);
|
|
408
|
-
}}
|
|
409
|
-
</Show>
|
|
410
|
-
</box>
|
|
411
|
-
</box>
|
|
412
|
-
</box>
|
|
413
|
-
);
|
|
414
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { loadConfig, saveConfig } from "@tt-agentboard/runtime";
|
|
2
|
-
import { MIN_DETAIL_PANEL_HEIGHT, DEFAULT_DETAIL_PANEL_HEIGHT } from "./constants";
|
|
3
|
-
|
|
4
|
-
export function clampDetailPanelHeight(height: number): number {
|
|
5
|
-
return Math.max(MIN_DETAIL_PANEL_HEIGHT, Math.round(height));
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function getStoredDetailPanelHeight(sessionName: string): number {
|
|
9
|
-
const stored = loadConfig().detailPanelHeights?.[sessionName];
|
|
10
|
-
return typeof stored === "number" ? clampDetailPanelHeight(stored) : DEFAULT_DETAIL_PANEL_HEIGHT;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function persistDetailPanelHeight(sessionName: string, height: number): void {
|
|
14
|
-
const config = loadConfig();
|
|
15
|
-
saveConfig({
|
|
16
|
-
detailPanelHeights: {
|
|
17
|
-
...(config.detailPanelHeights ?? {}),
|
|
18
|
-
[sessionName]: clampDetailPanelHeight(height),
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
}
|