@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.122",
3
+ "version": "0.0.124",
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,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 { SPINNERS, UNSEEN_ICON, BOLD, DIM, toneColor } from "../constants";
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 unseenAccentColor();
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 s = status();
51
- if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
52
- if (s === "waiting") return "";
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 unseenAccentColor();
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
- const n = props.session.name;
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 { ServerMessage, SessionData, ClientCommand, Theme } from "@tt-agentboard/runtime";
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
- 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
- });
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 / Alt+Down → reorder session
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 delta: -1 | 1 = key.name === "up" ? -1 : 1;
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: data().name,
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: data().name,
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
- </scrollbox>
703
- )}
704
- </Show>
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 = {};
@@ -61,9 +61,11 @@ export type {
61
61
  QuitNotify,
62
62
  ServerMessage,
63
63
  ClientCommand,
64
+ ReorderDelta,
64
65
  MetadataTone,
65
66
  MetadataStatus,
66
67
  MetadataProgress,
67
68
  MetadataLogEntry,
68
69
  SessionMetadata,
69
70
  } from "./shared";
71
+ export { truncate } from "./text-utils";
@@ -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) {
@@ -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 delta (-1 = up, 1 = down). */
63
- reorder(name: string, delta: -1 | 1): void {
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
- 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]!];
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: -1 | 1 }
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 }
@@ -0,0 +1,4 @@
1
+ /** Truncate a string to `max` chars, appending an ellipsis character if clipped. */
2
+ export function truncate(s: string, max: number): string {
3
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
4
+ }
@@ -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
- }