@towles/tool 0.0.122 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -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
- - Resizable detail panel with agent list + metadata
87
- - Mouse support (click, drag to resize)
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 + listening port hint (`⌁4201`, or `⌁4201+2` for multiple)
96
- - Row 3: metadata summary (status text + progress like `3/5` or `42%`)
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
- **`DetailPanel`** (`components/DetailPanel.tsx`) — expanded view for focused session
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`, `TONE_ICONS`), spark blocks for sparkline charts, theme list, tone-to-color mapping
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
- - `detail-panel-height.ts` — per-session detail panel height persistence (min 4 rows, default 10)
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 / Alt+Down → reorder session
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 = key.name === "up" ? -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: data().name,
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: data().name,
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
- </scrollbox>
703
- )}
704
- </Show>
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
- const result = [...watcherAgents];
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 by delta (-1 = up, 1 = down). */
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
- const newIdx = idx + delta;
67
- if (newIdx < 0 || newIdx >= this.order.length) return;
68
- // Swap
69
- [this.order[idx], this.order[newIdx]] = [this.order[newIdx]!, this.order[idx]!];
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,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
- }