@towles/tool 0.0.121 → 0.0.123
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 +181 -10
- package/packages/agentboard/apps/tui/src/components/cache-bar.ts +33 -0
- package/packages/agentboard/apps/tui/src/constants.ts +0 -11
- package/packages/agentboard/apps/tui/src/index.tsx +18 -147
- package/packages/agentboard/packages/runtime/src/config.ts +0 -2
- package/packages/agentboard/packages/runtime/src/server/index.ts +12 -2
- package/packages/agentboard/packages/runtime/src/server/session-order.ts +11 -6
- package/packages/agentboard/packages/runtime/src/shared.ts +1 -1
- package/packages/core/skills/towles-tool/SKILL.md +1 -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,19 @@
|
|
|
1
|
-
import { Show } from "solid-js";
|
|
1
|
+
import { createSignal, createEffect, createMemo, For, Show, onCleanup } from "solid-js";
|
|
2
2
|
import type { Accessor } from "solid-js";
|
|
3
|
-
import type { SessionData, Theme } from "@tt-agentboard/runtime";
|
|
3
|
+
import type { AgentStatus, SessionData, Theme } from "@tt-agentboard/runtime";
|
|
4
4
|
import { SPINNERS, UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
|
|
5
5
|
import { DiffStats } from "./DiffStats";
|
|
6
|
+
import { cacheBar, cacheBarColor, shortModel } from "./cache-bar";
|
|
7
|
+
|
|
8
|
+
const STATUS_TEXT: Record<AgentStatus, string> = {
|
|
9
|
+
idle: "",
|
|
10
|
+
running: "running",
|
|
11
|
+
done: "done",
|
|
12
|
+
error: "error",
|
|
13
|
+
waiting: "waiting",
|
|
14
|
+
question: "question",
|
|
15
|
+
interrupted: "stopped",
|
|
16
|
+
};
|
|
6
17
|
|
|
7
18
|
export interface SessionCardProps {
|
|
8
19
|
session: SessionData;
|
|
@@ -12,7 +23,10 @@ export interface SessionCardProps {
|
|
|
12
23
|
spinIdx: Accessor<number>;
|
|
13
24
|
theme: Accessor<Theme>;
|
|
14
25
|
statusColors: Accessor<Theme["status"]>;
|
|
26
|
+
focusedAgentIdx: number;
|
|
15
27
|
onSelect: () => void;
|
|
28
|
+
onDismissAgent: (agent: SessionData["agents"][number]) => void;
|
|
29
|
+
onFocusAgentPane: (agent: SessionData["agents"][number]) => void;
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
export function SessionCard(props: SessionCardProps) {
|
|
@@ -110,6 +124,16 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
110
124
|
return "transparent";
|
|
111
125
|
};
|
|
112
126
|
|
|
127
|
+
const agents = () => props.session.agents ?? [];
|
|
128
|
+
|
|
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
|
+
|
|
113
137
|
return (
|
|
114
138
|
<box flexDirection="column" flexShrink={0}>
|
|
115
139
|
<box
|
|
@@ -118,17 +142,13 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
118
142
|
onMouseDown={props.onSelect}
|
|
119
143
|
paddingLeft={1}
|
|
120
144
|
>
|
|
121
|
-
{/* Left accent — space-preserving, only colored for meaningful states */}
|
|
122
145
|
<text style={{ fg: accentColor() }}>{accentColor() === "transparent" ? " " : "▌"}</text>
|
|
123
146
|
|
|
124
|
-
{/* Index */}
|
|
125
147
|
<box width={3} flexShrink={0}>
|
|
126
148
|
<text style={{ fg: indexColor() }}>{String(props.index).padStart(2)}</text>
|
|
127
149
|
</box>
|
|
128
150
|
|
|
129
|
-
{/* Content */}
|
|
130
151
|
<box flexDirection="column" flexGrow={1} paddingRight={1}>
|
|
131
|
-
{/* Row 1: name + status */}
|
|
132
152
|
<box flexDirection="row">
|
|
133
153
|
<text truncate flexGrow={1}>
|
|
134
154
|
<span
|
|
@@ -151,19 +171,16 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
151
171
|
</Show>
|
|
152
172
|
</box>
|
|
153
173
|
|
|
154
|
-
{/* Row 2: branch */}
|
|
155
174
|
<Show when={props.session.branch}>
|
|
156
175
|
<text truncate>
|
|
157
176
|
<span style={{ fg: props.isFocused ? P().pink : P().overlay0 }}>{truncBranch()}</span>
|
|
158
177
|
</text>
|
|
159
178
|
</Show>
|
|
160
179
|
|
|
161
|
-
{/* Row 3: git diff stats */}
|
|
162
180
|
<Show when={hasDiff()}>
|
|
163
181
|
<DiffStats session={props.session} palette={() => P()} />
|
|
164
182
|
</Show>
|
|
165
183
|
|
|
166
|
-
{/* Row 3: metadata summary (status + progress) */}
|
|
167
184
|
<Show when={metaSummary()}>
|
|
168
185
|
<text truncate>
|
|
169
186
|
<span style={{ fg: toneColor(metaTone(), P()), attributes: DIM }}>
|
|
@@ -171,11 +188,165 @@ export function SessionCard(props: SessionCardProps) {
|
|
|
171
188
|
</span>
|
|
172
189
|
</text>
|
|
173
190
|
</Show>
|
|
191
|
+
|
|
192
|
+
<For each={agents()}>
|
|
193
|
+
{(agent, i) => (
|
|
194
|
+
<AgentRow
|
|
195
|
+
agent={agent}
|
|
196
|
+
palette={P}
|
|
197
|
+
statusColors={props.statusColors}
|
|
198
|
+
spinIdx={props.spinIdx}
|
|
199
|
+
now={now}
|
|
200
|
+
isKeyboardFocused={i() === props.focusedAgentIdx}
|
|
201
|
+
onDismiss={() => props.onDismissAgent(agent)}
|
|
202
|
+
onFocusPane={() => props.onFocusAgentPane(agent)}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</For>
|
|
174
206
|
</box>
|
|
175
207
|
</box>
|
|
176
208
|
|
|
177
|
-
{/* Breathing room — 1 empty line between cards */}
|
|
178
209
|
<box height={1} />
|
|
179
210
|
</box>
|
|
180
211
|
);
|
|
181
212
|
}
|
|
213
|
+
|
|
214
|
+
interface AgentRowProps {
|
|
215
|
+
agent: SessionData["agents"][number];
|
|
216
|
+
palette: Accessor<Theme["palette"]>;
|
|
217
|
+
statusColors: Accessor<Theme["status"]>;
|
|
218
|
+
spinIdx: Accessor<number>;
|
|
219
|
+
now: Accessor<number>;
|
|
220
|
+
isKeyboardFocused: boolean;
|
|
221
|
+
onDismiss: () => void;
|
|
222
|
+
onFocusPane: () => void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function AgentRow(props: AgentRowProps) {
|
|
226
|
+
const P = () => props.palette();
|
|
227
|
+
const SC = () => props.statusColors();
|
|
228
|
+
const [isDismissHover, setIsDismissHover] = createSignal(false);
|
|
229
|
+
const [isFlash, setIsFlash] = createSignal(false);
|
|
230
|
+
|
|
231
|
+
const isTerminal = () => ["done", "error", "interrupted"].includes(props.agent.status);
|
|
232
|
+
const isUnseen = () => isTerminal() && props.agent.unseen === true;
|
|
233
|
+
|
|
234
|
+
const icon = () => {
|
|
235
|
+
if (isUnseen()) return UNSEEN_ICON;
|
|
236
|
+
if (isTerminal())
|
|
237
|
+
return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
|
|
238
|
+
if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
|
|
239
|
+
if (props.agent.status === "waiting") return "◉";
|
|
240
|
+
if (props.agent.status === "question") return "?";
|
|
241
|
+
return "○";
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const color = () => {
|
|
245
|
+
if (isTerminal()) {
|
|
246
|
+
if (props.agent.status === "error") return P().red;
|
|
247
|
+
if (props.agent.status === "interrupted") return P().peach;
|
|
248
|
+
return isUnseen() ? P().teal : P().green;
|
|
249
|
+
}
|
|
250
|
+
return SC()[props.agent.status];
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const statusText = () => STATUS_TEXT[props.agent.status];
|
|
254
|
+
|
|
255
|
+
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
256
|
+
const triggerFlash = () => {
|
|
257
|
+
setIsFlash(true);
|
|
258
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
259
|
+
flashTimer = setTimeout(() => setIsFlash(false), 150);
|
|
260
|
+
};
|
|
261
|
+
onCleanup(() => {
|
|
262
|
+
if (flashTimer) clearTimeout(flashTimer);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const bgColor = () => {
|
|
266
|
+
if (isFlash()) return P().surface1;
|
|
267
|
+
if (props.isKeyboardFocused) return P().surface0;
|
|
268
|
+
return "transparent";
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<box
|
|
273
|
+
flexDirection="column"
|
|
274
|
+
flexShrink={0}
|
|
275
|
+
backgroundColor={bgColor()}
|
|
276
|
+
onMouseDown={(event) => {
|
|
277
|
+
if (event.target?.id === "dismiss") return;
|
|
278
|
+
triggerFlash();
|
|
279
|
+
props.onFocusPane();
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<box flexDirection="row">
|
|
283
|
+
<text flexGrow={1} truncate>
|
|
284
|
+
<span style={{ fg: color() }}>{icon()}</span>
|
|
285
|
+
<span
|
|
286
|
+
style={{
|
|
287
|
+
fg: props.isKeyboardFocused ? P().text : P().subtext1,
|
|
288
|
+
attributes: props.isKeyboardFocused ? BOLD : undefined,
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{" "}
|
|
292
|
+
{props.agent.agent}
|
|
293
|
+
</span>
|
|
294
|
+
</text>
|
|
295
|
+
<Show when={!isUnseen()}>
|
|
296
|
+
<text flexShrink={0}>
|
|
297
|
+
<span style={{ fg: color(), attributes: DIM }}>{statusText()}</span>
|
|
298
|
+
</text>
|
|
299
|
+
</Show>
|
|
300
|
+
<text
|
|
301
|
+
flexShrink={0}
|
|
302
|
+
onMouseDown={(event) => {
|
|
303
|
+
event.preventDefault();
|
|
304
|
+
event.stopPropagation();
|
|
305
|
+
props.onDismiss();
|
|
306
|
+
}}
|
|
307
|
+
onMouseOver={() => setIsDismissHover(true)}
|
|
308
|
+
onMouseOut={() => setIsDismissHover(false)}
|
|
309
|
+
>
|
|
310
|
+
<span style={{ fg: isDismissHover() ? P().red : P().overlay0 }}>{" ✕"}</span>
|
|
311
|
+
</text>
|
|
312
|
+
</box>
|
|
313
|
+
|
|
314
|
+
<Show when={props.agent.threadName}>
|
|
315
|
+
<text truncate>
|
|
316
|
+
<span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
|
|
317
|
+
{props.agent.threadName!.replace(/\s+/g, " ").trim()}
|
|
318
|
+
</span>
|
|
319
|
+
</text>
|
|
320
|
+
</Show>
|
|
321
|
+
|
|
322
|
+
<Show when={props.agent.details}>
|
|
323
|
+
{(d) => {
|
|
324
|
+
const details = d();
|
|
325
|
+
const model = () => (details.model ? shortModel(details.model) : "");
|
|
326
|
+
const hasCache = () => details.cacheExpiresAt != null && details.cacheTtlMs != null;
|
|
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;
|
|
333
|
+
return (
|
|
334
|
+
<Show when={model() || hasCache()}>
|
|
335
|
+
<text truncate>
|
|
336
|
+
<Show when={model()}>
|
|
337
|
+
<span style={{ fg: P().subtext0, attributes: DIM }}>{model()}</span>
|
|
338
|
+
</Show>
|
|
339
|
+
<Show when={hasCache()}>
|
|
340
|
+
<span style={{ fg: P().overlay0, attributes: DIM }}>
|
|
341
|
+
{model() ? " · cache " : "cache "}
|
|
342
|
+
</span>
|
|
343
|
+
<span style={{ fg: barColor() }}>{bar()}</span>
|
|
344
|
+
</Show>
|
|
345
|
+
</text>
|
|
346
|
+
</Show>
|
|
347
|
+
);
|
|
348
|
+
}}
|
|
349
|
+
</Show>
|
|
350
|
+
</box>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
@@ -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
|
+
/** 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
|
+
}
|
|
@@ -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,10 @@ 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
18
|
import type { ServerMessage, SessionData, ClientCommand, Theme } from "@tt-agentboard/runtime";
|
|
20
19
|
import { SessionCard } from "./components/SessionCard";
|
|
21
|
-
import { DetailPanel } from "./components/DetailPanel";
|
|
22
20
|
import { StatusBar } from "./components/StatusBar";
|
|
23
21
|
import { computeSessionStatusCounts } from "./session-status";
|
|
24
22
|
import {
|
|
@@ -27,19 +25,7 @@ import {
|
|
|
27
25
|
getClientTty,
|
|
28
26
|
getLocalSessionName,
|
|
29
27
|
} 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";
|
|
28
|
+
import { SPINNERS, BOLD, DIM, DIVIDER, logResizeDebug } from "./constants";
|
|
43
29
|
|
|
44
30
|
const muxCtx = detectMuxContext();
|
|
45
31
|
|
|
@@ -92,14 +78,9 @@ function App() {
|
|
|
92
78
|
const [sessions, setSessions] = createStore<SessionData[]>([]);
|
|
93
79
|
const [focusedSession, setFocusedSession] = createSignal<string | null>(null);
|
|
94
80
|
const [currentSession, setCurrentSession] = createSignal<string | null>(null);
|
|
95
|
-
const [mySession, setMySession] = createSignal<string | null>(null);
|
|
96
81
|
const [connected, setConnected] = createSignal(false);
|
|
97
82
|
const [spinIdx, setSpinIdx] = createSignal(0);
|
|
98
|
-
const [detailPanelHeight, setDetailPanelHeight] = createSignal(DEFAULT_DETAIL_PANEL_HEIGHT);
|
|
99
83
|
const [preferredEditor, setPreferredEditor] = createSignal("code");
|
|
100
|
-
const [isDetailResizeHover, setIsDetailResizeHover] = createSignal(false);
|
|
101
|
-
const [isDetailResizing, setIsDetailResizing] = createSignal(false);
|
|
102
|
-
const detailPanelSessionName = createMemo(() => focusedSession() ?? mySession());
|
|
103
84
|
|
|
104
85
|
// --- Panel focus: sessions list vs agent detail ---
|
|
105
86
|
type PanelFocus = "sessions" | "agents";
|
|
@@ -123,8 +104,6 @@ function App() {
|
|
|
123
104
|
const [clientTty, setClientTty] = createSignal(getClientTty(muxCtx));
|
|
124
105
|
let ws: WebSocket | null = null;
|
|
125
106
|
let startupFocusSynced = false;
|
|
126
|
-
let detailResizeStartY = 0;
|
|
127
|
-
let detailResizeStartHeight = DEFAULT_DETAIL_PANEL_HEIGHT;
|
|
128
107
|
const startupSessionName = getLocalSessionName(muxCtx);
|
|
129
108
|
|
|
130
109
|
const focusedData = createMemo(() => sessions.find((s) => s.name === focusedSession()) ?? null);
|
|
@@ -240,76 +219,6 @@ function App() {
|
|
|
240
219
|
);
|
|
241
220
|
}
|
|
242
221
|
|
|
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
222
|
function createNewSession() {
|
|
314
223
|
if (muxCtx.type !== "tmux") {
|
|
315
224
|
send({ type: "new-session" });
|
|
@@ -418,7 +327,6 @@ function App() {
|
|
|
418
327
|
setFocusedSession(msg.focusedSession);
|
|
419
328
|
setCurrentSession(msg.currentSession);
|
|
420
329
|
} else if (msg.type === "your-session") {
|
|
421
|
-
setMySession(msg.name);
|
|
422
330
|
if (msg.clientTty) setClientTty(msg.clientTty);
|
|
423
331
|
|
|
424
332
|
if (!startupFocusSynced && sessions.some((session) => session.name === msg.name)) {
|
|
@@ -461,27 +369,6 @@ function App() {
|
|
|
461
369
|
onCleanup(() => clearInterval(interval));
|
|
462
370
|
});
|
|
463
371
|
|
|
464
|
-
createEffect(() => {
|
|
465
|
-
const sessionName = detailPanelSessionName();
|
|
466
|
-
if (!sessionName) return;
|
|
467
|
-
const storedHeight = getStoredDetailPanelHeight(sessionName);
|
|
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
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
372
|
useKeyboard((key) => {
|
|
486
373
|
const currentModal = modal();
|
|
487
374
|
|
|
@@ -506,11 +393,17 @@ function App() {
|
|
|
506
393
|
}
|
|
507
394
|
|
|
508
395
|
// --- Normal mode keybindings ---
|
|
509
|
-
// Alt+Up
|
|
396
|
+
// Alt+Up/Down → reorder session ±1. Alt+Shift+Up/Down → jump to top/bottom.
|
|
510
397
|
if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
|
|
511
398
|
const focused = focusedSession();
|
|
512
399
|
if (focused) {
|
|
513
|
-
const delta: -1 | 1
|
|
400
|
+
const delta: -1 | 1 | "top" | "bottom" = key.shift
|
|
401
|
+
? key.name === "up"
|
|
402
|
+
? "top"
|
|
403
|
+
: "bottom"
|
|
404
|
+
: key.name === "up"
|
|
405
|
+
? -1
|
|
406
|
+
: 1;
|
|
514
407
|
send({ type: "reorder-session", name: focused, delta });
|
|
515
408
|
}
|
|
516
409
|
return;
|
|
@@ -650,58 +543,35 @@ function App() {
|
|
|
650
543
|
spinIdx={spinIdx}
|
|
651
544
|
theme={theme}
|
|
652
545
|
statusColors={S}
|
|
546
|
+
focusedAgentIdx={
|
|
547
|
+
isFocused(session.name) && panelFocus() === "agents" ? focusedAgentIdx() : -1
|
|
548
|
+
}
|
|
653
549
|
onSelect={() => {
|
|
654
550
|
setFocusedSession(session.name);
|
|
655
551
|
send({ type: "focus-session", name: session.name });
|
|
656
552
|
switchToSession(session.name);
|
|
657
553
|
}}
|
|
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
554
|
onDismissAgent={(agent) => {
|
|
674
555
|
send({
|
|
675
556
|
type: "dismiss-agent",
|
|
676
|
-
session:
|
|
557
|
+
session: session.name,
|
|
677
558
|
agent: agent.agent,
|
|
678
559
|
threadId: agent.threadId,
|
|
679
560
|
});
|
|
680
561
|
}}
|
|
681
562
|
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
563
|
send({
|
|
688
564
|
type: "focus-agent-pane",
|
|
689
|
-
session:
|
|
565
|
+
session: session.name,
|
|
690
566
|
agent: agent.agent,
|
|
691
567
|
threadId: agent.threadId,
|
|
692
568
|
threadName: agent.threadName,
|
|
693
569
|
});
|
|
694
570
|
}}
|
|
695
|
-
isResizeHover={isDetailResizeHover()}
|
|
696
|
-
isResizing={isDetailResizing()}
|
|
697
|
-
onResizeStart={beginDetailResize}
|
|
698
|
-
onResizeDrag={handleDetailResizeDrag}
|
|
699
|
-
onResizeEnd={endDetailResize}
|
|
700
|
-
onResizeHoverChange={setIsDetailResizeHover}
|
|
701
571
|
/>
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
</
|
|
572
|
+
)}
|
|
573
|
+
</For>
|
|
574
|
+
</scrollbox>
|
|
705
575
|
|
|
706
576
|
{/* Footer */}
|
|
707
577
|
<box flexDirection="column" paddingLeft={1} paddingBottom={1} paddingTop={0} flexShrink={0}>
|
|
@@ -812,6 +682,7 @@ const HELP_KEYS: [string, string][] = [
|
|
|
812
682
|
["→/l", "Agents panel"],
|
|
813
683
|
["←/h/Esc", "Back to sessions"],
|
|
814
684
|
["Alt+↑↓", "Reorder sessions"],
|
|
685
|
+
["Alt+Shift+↑↓", "Move to top/bottom"],
|
|
815
686
|
["q", "Quit"],
|
|
816
687
|
];
|
|
817
688
|
|
|
@@ -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) {
|
|
@@ -59,14 +59,19 @@ export class SessionOrder {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** Move a session
|
|
63
|
-
reorder(name: string, delta: -1 | 1): void {
|
|
62
|
+
/** Move a session: delta -1 = up, 1 = down, "top" / "bottom" = jump to end. */
|
|
63
|
+
reorder(name: string, delta: -1 | 1 | "top" | "bottom"): void {
|
|
64
64
|
const idx = this.order.indexOf(name);
|
|
65
65
|
if (idx === -1) return;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
if (delta === "top") {
|
|
67
|
+
this.order = [name, ...this.order.filter((n) => n !== name)];
|
|
68
|
+
} else if (delta === "bottom") {
|
|
69
|
+
this.order = [...this.order.filter((n) => n !== name), name];
|
|
70
|
+
} else {
|
|
71
|
+
const newIdx = idx + delta;
|
|
72
|
+
if (newIdx < 0 || newIdx >= this.order.length) return;
|
|
73
|
+
[this.order[idx], this.order[newIdx]] = [this.order[newIdx]!, this.order[idx]!];
|
|
74
|
+
}
|
|
70
75
|
this.save();
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -112,7 +112,7 @@ export type ClientCommand =
|
|
|
112
112
|
| { type: "switch-index"; index: number }
|
|
113
113
|
| { type: "new-session" }
|
|
114
114
|
| { type: "kill-session"; name: string }
|
|
115
|
-
| { type: "reorder-session"; name: string; delta: -1 | 1 }
|
|
115
|
+
| { type: "reorder-session"; name: string; delta: -1 | 1 | "top" | "bottom" }
|
|
116
116
|
| { type: "refresh" }
|
|
117
117
|
| { type: "move-focus"; delta: -1 | 1 }
|
|
118
118
|
| { type: "focus-session"; name: string }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: towles-tool
|
|
3
3
|
description: Use towles-tool (`tt`) CLI for git helpers, journaling, and developer utilities. Use when asked about "tt commands", "create branch from issue", "daily notes", "meeting notes", or "check dependencies".
|
|
4
|
+
user_invocable: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
# towles-tool CLI
|
|
@@ -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
|
-
}
|