@towles/tool 0.0.107 → 0.0.109

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.
Files changed (77) hide show
  1. package/README.md +7 -1
  2. package/package.json +2 -1
  3. package/plugins/tt-agentboard/README.md +160 -0
  4. package/plugins/tt-agentboard/apps/server/package.json +20 -0
  5. package/plugins/tt-agentboard/apps/server/src/main.ts +60 -0
  6. package/plugins/tt-agentboard/apps/tui/build.ts +11 -0
  7. package/plugins/tt-agentboard/apps/tui/bunfig.toml +1 -0
  8. package/plugins/tt-agentboard/apps/tui/package.json +23 -0
  9. package/plugins/tt-agentboard/apps/tui/scripts/sessionizer.sh +36 -0
  10. package/plugins/tt-agentboard/apps/tui/src/components/DetailPanel.tsx +350 -0
  11. package/plugins/tt-agentboard/apps/tui/src/components/DiffStats.tsx +33 -0
  12. package/plugins/tt-agentboard/apps/tui/src/components/SessionCard.tsx +177 -0
  13. package/plugins/tt-agentboard/apps/tui/src/components/StatusBar.tsx +49 -0
  14. package/plugins/tt-agentboard/apps/tui/src/constants.ts +46 -0
  15. package/plugins/tt-agentboard/apps/tui/src/detail-panel-height.ts +21 -0
  16. package/plugins/tt-agentboard/apps/tui/src/index.tsx +880 -0
  17. package/plugins/tt-agentboard/apps/tui/src/mux-context.ts +61 -0
  18. package/plugins/tt-agentboard/apps/tui/tsconfig.json +15 -0
  19. package/plugins/tt-agentboard/bun.lock +444 -0
  20. package/plugins/tt-agentboard/package.json +26 -0
  21. package/plugins/tt-agentboard/packages/mux-tmux/package.json +14 -0
  22. package/plugins/tt-agentboard/packages/mux-tmux/src/client.ts +550 -0
  23. package/plugins/tt-agentboard/packages/mux-tmux/src/index.ts +18 -0
  24. package/plugins/tt-agentboard/packages/mux-tmux/src/provider.ts +259 -0
  25. package/plugins/tt-agentboard/packages/mux-tmux/tsconfig.json +13 -0
  26. package/plugins/tt-agentboard/packages/runtime/package.json +14 -0
  27. package/plugins/tt-agentboard/packages/runtime/src/agents/tracker.ts +233 -0
  28. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/amp.ts +316 -0
  29. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/claude-code.ts +374 -0
  30. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/codex.ts +364 -0
  31. package/plugins/tt-agentboard/packages/runtime/src/agents/watchers/opencode.ts +249 -0
  32. package/plugins/tt-agentboard/packages/runtime/src/config.ts +70 -0
  33. package/plugins/tt-agentboard/packages/runtime/src/contracts/agent-watcher.ts +38 -0
  34. package/plugins/tt-agentboard/packages/runtime/src/contracts/agent.ts +16 -0
  35. package/plugins/tt-agentboard/packages/runtime/src/contracts/index.ts +3 -0
  36. package/plugins/tt-agentboard/packages/runtime/src/contracts/mux.ts +148 -0
  37. package/plugins/tt-agentboard/packages/runtime/src/debug.ts +19 -0
  38. package/plugins/tt-agentboard/packages/runtime/src/index.ts +69 -0
  39. package/plugins/tt-agentboard/packages/runtime/src/mux/detect.ts +20 -0
  40. package/plugins/tt-agentboard/packages/runtime/src/mux/registry.ts +45 -0
  41. package/plugins/tt-agentboard/packages/runtime/src/plugins/loader.ts +152 -0
  42. package/plugins/tt-agentboard/packages/runtime/src/server/context.ts +112 -0
  43. package/plugins/tt-agentboard/packages/runtime/src/server/git-info.ts +164 -0
  44. package/plugins/tt-agentboard/packages/runtime/src/server/index.ts +1753 -0
  45. package/plugins/tt-agentboard/packages/runtime/src/server/launcher.ts +71 -0
  46. package/plugins/tt-agentboard/packages/runtime/src/server/metadata-store.ts +86 -0
  47. package/plugins/tt-agentboard/packages/runtime/src/server/pane-scanner.ts +327 -0
  48. package/plugins/tt-agentboard/packages/runtime/src/server/port-scanner.ts +155 -0
  49. package/plugins/tt-agentboard/packages/runtime/src/server/session-order.ts +127 -0
  50. package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-manager.ts +232 -0
  51. package/plugins/tt-agentboard/packages/runtime/src/server/sidebar-width-sync.ts +66 -0
  52. package/plugins/tt-agentboard/packages/runtime/src/shared.ts +179 -0
  53. package/plugins/tt-agentboard/packages/runtime/src/themes.ts +750 -0
  54. package/plugins/tt-agentboard/packages/runtime/test/config.test.ts +83 -0
  55. package/plugins/tt-agentboard/packages/runtime/test/tracker.test.ts +172 -0
  56. package/plugins/tt-agentboard/packages/runtime/tsconfig.json +13 -0
  57. package/plugins/tt-agentboard/tsconfig.json +19 -0
  58. package/plugins/tt-auto-claude/.claude-plugin/plugin.json +8 -0
  59. package/plugins/tt-auto-claude/commands/create-issue.md +20 -0
  60. package/plugins/tt-auto-claude/commands/list.md +21 -0
  61. package/plugins/tt-auto-claude/skills/auto-claude/SKILL.md +71 -0
  62. package/plugins/tt-core/.claude-plugin/plugin.json +8 -0
  63. package/plugins/tt-core/README.md +18 -0
  64. package/plugins/tt-core/commands/improve-architecture.md +66 -0
  65. package/plugins/tt-core/commands/interview-me.md +38 -0
  66. package/plugins/tt-core/commands/prd-to-issues.md +49 -0
  67. package/plugins/tt-core/commands/refine-text.md +30 -0
  68. package/plugins/tt-core/commands/task.md +37 -0
  69. package/plugins/tt-core/commands/tdd.md +69 -0
  70. package/plugins/tt-core/commands/write-prd.md +69 -0
  71. package/plugins/tt-core/promptfooconfig.interview-me.yaml +155 -0
  72. package/plugins/tt-core/promptfooconfig.refine-text.yaml +242 -0
  73. package/plugins/tt-core/promptfooconfig.tdd.yaml +144 -0
  74. package/plugins/tt-core/promptfooconfig.write-prd.yaml +145 -0
  75. package/plugins/tt-core/skills/towles-tool/SKILL.md +35 -0
  76. package/src/cli.ts +2 -1
  77. package/src/commands/agentboard.ts +19 -2
@@ -0,0 +1,880 @@
1
+ import { render, useKeyboard, useRenderer } from "@opentui/solid";
2
+ import { appendFileSync } from "node:fs";
3
+ import {
4
+ createSignal,
5
+ createEffect,
6
+ onCleanup,
7
+ onMount,
8
+ batch,
9
+ For,
10
+ Show,
11
+ createMemo,
12
+ createSelector,
13
+ } from "solid-js";
14
+ import type { Accessor } from "solid-js";
15
+ import { createStore, reconcile } from "solid-js/store";
16
+ import { TextAttributes } from "@opentui/core";
17
+ import type { MouseEvent } from "@opentui/core";
18
+
19
+ import {
20
+ ensureServer,
21
+ SERVER_PORT,
22
+ SERVER_HOST,
23
+ loadConfig,
24
+ resolveTheme,
25
+ saveConfig,
26
+ } from "@tt-agentboard/runtime";
27
+ import type { ServerMessage, SessionData, ClientCommand, Theme } from "@tt-agentboard/runtime";
28
+ import { TmuxClient } from "@tt-agentboard/mux-tmux";
29
+ import { SessionCard } from "./components/SessionCard";
30
+ import { DetailPanel } from "./components/DetailPanel";
31
+ import { StatusBar } from "./components/StatusBar";
32
+
33
+ // Detect tmux context (tmux only)
34
+ type MuxContext = { type: "tmux"; sdk: TmuxClient; paneId: string } | { type: "none" };
35
+
36
+ function detectMuxContext(): MuxContext {
37
+ if (process.env.TMUX_PANE && process.env.TMUX) {
38
+ return { type: "tmux", sdk: new TmuxClient(), paneId: process.env.TMUX_PANE };
39
+ }
40
+ return { type: "none" };
41
+ }
42
+
43
+ const muxCtx = detectMuxContext();
44
+
45
+ const SPINNERS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
46
+ const BOLD = TextAttributes.BOLD;
47
+ const DIM = TextAttributes.DIM;
48
+ const DEFAULT_DETAIL_PANEL_HEIGHT = 10;
49
+ const MIN_DETAIL_PANEL_HEIGHT = 4;
50
+ const DIVIDER = "─".repeat(200);
51
+ const RESIZE_DEBUG_LOG = "/tmp/agentboard-tui-resize.log";
52
+
53
+ const TUI_DEBUG = !!process.env.TT_AGENTBOARD_DEBUG;
54
+
55
+ function logResizeDebug(message: string, data?: Record<string, unknown>): void {
56
+ if (!TUI_DEBUG) return;
57
+ const ts = new Date().toISOString();
58
+ const extra = data ? ` ${JSON.stringify(data)}` : "";
59
+ try {
60
+ appendFileSync(RESIZE_DEBUG_LOG, `[${ts}] [pid:${process.pid}] ${message}${extra}\n`);
61
+ } catch {}
62
+ }
63
+
64
+ function clampDetailPanelHeight(height: number): number {
65
+ return Math.max(MIN_DETAIL_PANEL_HEIGHT, Math.round(height));
66
+ }
67
+
68
+ function getStoredDetailPanelHeight(sessionName: string): number {
69
+ const stored = loadConfig().detailPanelHeights?.[sessionName];
70
+ return typeof stored === "number" ? clampDetailPanelHeight(stored) : DEFAULT_DETAIL_PANEL_HEIGHT;
71
+ }
72
+
73
+ function persistDetailPanelHeight(sessionName: string, height: number): void {
74
+ const config = loadConfig();
75
+ saveConfig({
76
+ detailPanelHeights: {
77
+ ...(config.detailPanelHeights ?? {}),
78
+ [sessionName]: clampDetailPanelHeight(height),
79
+ },
80
+ });
81
+ }
82
+
83
+ /** Refocus the main (non-sidebar) pane after TUI capability detection finishes.
84
+ * This must happen from the TUI process — doing it from the server races with
85
+ * capability query responses and leaks escape sequences to the main pane. */
86
+ function refocusMainPane() {
87
+ if (muxCtx.type === "tmux") {
88
+ try {
89
+ // Use the TUI's own pane ID to find its current window (handles stash restore
90
+ // where the pane may have moved to a different window than the original).
91
+ const windowId =
92
+ process.env.REFOCUS_WINDOW ||
93
+ Bun.spawnSync(["tmux", "display-message", "-t", muxCtx.paneId, "-p", "#{window_id}"], {
94
+ stdout: "pipe",
95
+ stderr: "pipe",
96
+ })
97
+ .stdout.toString()
98
+ .trim();
99
+ if (!windowId) return;
100
+ const r = Bun.spawnSync(
101
+ ["tmux", "list-panes", "-t", windowId, "-F", "#{pane_id} #{pane_title}"],
102
+ { stdout: "pipe", stderr: "pipe" },
103
+ );
104
+ const lines = r.stdout.toString().trim().split("\n");
105
+ const main = lines.find((l) => !l.includes("agentboard-sidebar"));
106
+ if (main) {
107
+ const paneId = main.split(" ")[0];
108
+ Bun.spawnSync(["tmux", "select-pane", "-t", paneId], { stdout: "pipe", stderr: "pipe" });
109
+ }
110
+ } catch {}
111
+ }
112
+ }
113
+
114
+ function getClientTty(): string {
115
+ if (muxCtx.type === "tmux") {
116
+ const { sdk, paneId } = muxCtx;
117
+ const sessName = sdk.display("#{session_name}", { target: paneId });
118
+ if (sessName) {
119
+ const clients = sdk.listClients();
120
+ const client = clients.find((c) => c.sessionName === sessName);
121
+ if (client) return client.tty;
122
+ }
123
+ return sdk.getClientTty();
124
+ }
125
+ return "";
126
+ }
127
+
128
+ function getLocalSessionName(): string | null {
129
+ if (muxCtx.type === "tmux") {
130
+ const sessionName = muxCtx.sdk.display("#{session_name}", { target: muxCtx.paneId });
131
+ return sessionName || null;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function App() {
137
+ const renderer = useRenderer();
138
+
139
+ // --- Theme state (driven by server) ---
140
+ const [theme, setTheme] = createSignal<Theme>(resolveTheme(undefined));
141
+ const P = () => theme().palette;
142
+ const S = () => theme().status;
143
+
144
+ const [sessions, setSessions] = createStore<SessionData[]>([]);
145
+ const [focusedSession, setFocusedSession] = createSignal<string | null>(null);
146
+ const [currentSession, setCurrentSession] = createSignal<string | null>(null);
147
+ const [mySession, setMySession] = createSignal<string | null>(null);
148
+ const [connected, setConnected] = createSignal(false);
149
+ const [spinIdx, setSpinIdx] = createSignal(0);
150
+ const [detailPanelHeight, setDetailPanelHeight] = createSignal(DEFAULT_DETAIL_PANEL_HEIGHT);
151
+ const [isDetailResizeHover, setIsDetailResizeHover] = createSignal(false);
152
+ const [isDetailResizing, setIsDetailResizing] = createSignal(false);
153
+ const detailPanelSessionName = createMemo(() => focusedSession() ?? mySession());
154
+
155
+ // --- Panel focus: sessions list vs agent detail ---
156
+ type PanelFocus = "sessions" | "agents";
157
+ const [panelFocus, setPanelFocus] = createSignal<PanelFocus>("sessions");
158
+ const [focusedAgentIdx, setFocusedAgentIdx] = createSignal(0);
159
+
160
+ // --- Modal state ---
161
+ const [modal, setModal] = createSignal<"none" | "confirm-kill" | "help">("none");
162
+ const [killTarget, setKillTarget] = createSignal<string | null>(null);
163
+
164
+ const [clientTty, setClientTty] = createSignal(getClientTty());
165
+ let ws: WebSocket | null = null;
166
+ let startupFocusSynced = false;
167
+ let detailResizeStartY = 0;
168
+ let detailResizeStartHeight = DEFAULT_DETAIL_PANEL_HEIGHT;
169
+ const startupSessionName = getLocalSessionName();
170
+
171
+ const focusedData = createMemo(() => sessions.find((s) => s.name === focusedSession()) ?? null);
172
+
173
+ function send(cmd: ClientCommand) {
174
+ if (connected() && ws) ws.send(JSON.stringify(cmd));
175
+ }
176
+
177
+ function switchToSession(name: string) {
178
+ // Optimistic local update — makes rapid Tab repeat instant by removing
179
+ // the server/hook round-trip from the next-Tab decision.
180
+ // The server's focus/state broadcast will reconcile if needed.
181
+ setCurrentSession(name);
182
+ setFocusedSession(name);
183
+ setPanelFocus("sessions");
184
+ setFocusedAgentIdx(0);
185
+ send({ type: "switch-session", name });
186
+ }
187
+
188
+ function reIdentify() {
189
+ const sessionName = getLocalSessionName();
190
+ if (!sessionName) return;
191
+
192
+ if (muxCtx.type === "tmux") {
193
+ send({ type: "identify-pane", paneId: muxCtx.paneId, sessionName });
194
+ }
195
+ }
196
+
197
+ function moveLocalFocus(delta: -1 | 1) {
198
+ const list = sessions;
199
+ if (list.length === 0) return;
200
+
201
+ const current = focusedSession();
202
+ const currentIdx = Math.max(
203
+ 0,
204
+ list.findIndex((s) => s.name === current),
205
+ );
206
+ const nextIdx = Math.max(0, Math.min(list.length - 1, currentIdx + delta));
207
+ const next = list[nextIdx]?.name ?? null;
208
+
209
+ if (!next || next === current) return;
210
+
211
+ setFocusedSession(next);
212
+ send({ type: "focus-session", name: next });
213
+ }
214
+
215
+ function moveAgentFocus(delta: -1 | 1) {
216
+ const data = focusedData();
217
+ const agents = data?.agents ?? [];
218
+ if (agents.length === 0) return;
219
+ const idx = focusedAgentIdx();
220
+ const next = Math.max(0, Math.min(agents.length - 1, idx + delta));
221
+ setFocusedAgentIdx(next);
222
+ }
223
+
224
+ function activateFocusedAgent() {
225
+ const data = focusedData();
226
+ const agents = data?.agents ?? [];
227
+ const agent = agents[focusedAgentIdx()];
228
+ if (!agent || !data) return;
229
+ if (TUI_DEBUG)
230
+ appendFileSync(
231
+ "/tmp/agentboard-tui-agent-click.log",
232
+ `[${new Date().toISOString()}] keyboard focus-agent-pane session=${data.name} agent=${agent.agent} threadId=${agent.threadId} threadName=${agent.threadName}\n`,
233
+ );
234
+ send({
235
+ type: "focus-agent-pane",
236
+ session: data.name,
237
+ agent: agent.agent,
238
+ threadId: agent.threadId,
239
+ threadName: agent.threadName,
240
+ });
241
+ }
242
+
243
+ function dismissFocusedAgent() {
244
+ const data = focusedData();
245
+ const agents = data?.agents ?? [];
246
+ const agent = agents[focusedAgentIdx()];
247
+ if (!agent || !data) return;
248
+ send({
249
+ type: "dismiss-agent",
250
+ session: data.name,
251
+ agent: agent.agent,
252
+ threadId: agent.threadId,
253
+ });
254
+ // Adjust index if we dismissed the last item
255
+ if (focusedAgentIdx() >= agents.length - 1 && agents.length > 1) {
256
+ setFocusedAgentIdx(agents.length - 2);
257
+ }
258
+ // If no agents left, go back to sessions
259
+ if (agents.length <= 1) setPanelFocus("sessions");
260
+ }
261
+
262
+ function killFocusedAgentPane() {
263
+ const data = focusedData();
264
+ const agents = data?.agents ?? [];
265
+ const agent = agents[focusedAgentIdx()];
266
+ if (!agent || !data) return;
267
+ send({
268
+ type: "kill-agent-pane",
269
+ session: data.name,
270
+ agent: agent.agent,
271
+ threadId: agent.threadId,
272
+ threadName: agent.threadName,
273
+ });
274
+ }
275
+
276
+ function beginDetailResize(event: MouseEvent) {
277
+ logResizeDebug("beginDetailResize", {
278
+ button: event.button,
279
+ x: event.x,
280
+ y: event.y,
281
+ currentHeight: detailPanelHeight(),
282
+ session: detailPanelSessionName(),
283
+ target: event.target?.id ?? null,
284
+ });
285
+ if (event.button !== 0) return;
286
+ (renderer as any).setCapturedRenderable?.(event.target ?? undefined);
287
+ detailResizeStartY = event.y;
288
+ detailResizeStartHeight = detailPanelHeight();
289
+ setIsDetailResizing(true);
290
+ event.stopPropagation();
291
+ }
292
+
293
+ function handleDetailResizeDrag(event: MouseEvent) {
294
+ logResizeDebug("handleDetailResizeDrag", {
295
+ x: event.x,
296
+ y: event.y,
297
+ isResizing: isDetailResizing(),
298
+ startY: detailResizeStartY,
299
+ startHeight: detailResizeStartHeight,
300
+ currentHeight: detailPanelHeight(),
301
+ session: detailPanelSessionName(),
302
+ });
303
+ if (!isDetailResizing()) return;
304
+ const delta = detailResizeStartY - event.y;
305
+ const nextHeight = clampDetailPanelHeight(detailResizeStartHeight + delta);
306
+ setDetailPanelHeight(nextHeight);
307
+ logResizeDebug("handleDetailResizeDrag:applied", {
308
+ delta,
309
+ nextHeight,
310
+ session: detailPanelSessionName(),
311
+ });
312
+ event.stopPropagation();
313
+ }
314
+
315
+ function endDetailResize(event?: MouseEvent) {
316
+ logResizeDebug("endDetailResize", {
317
+ x: event?.x,
318
+ y: event?.y,
319
+ isResizing: isDetailResizing(),
320
+ currentHeight: detailPanelHeight(),
321
+ session: detailPanelSessionName(),
322
+ target: event?.target?.id ?? null,
323
+ });
324
+ if (!isDetailResizing()) return;
325
+ (renderer as any).setCapturedRenderable?.(undefined);
326
+ setIsDetailResizing(false);
327
+ setIsDetailResizeHover(false);
328
+
329
+ const sessionName = detailPanelSessionName();
330
+ if (sessionName) {
331
+ persistDetailPanelHeight(sessionName, detailPanelHeight());
332
+ logResizeDebug("endDetailResize:persisted", {
333
+ session: sessionName,
334
+ height: detailPanelHeight(),
335
+ });
336
+ }
337
+
338
+ event?.stopPropagation();
339
+ }
340
+
341
+ function createNewSession() {
342
+ if (muxCtx.type !== "tmux") {
343
+ send({ type: "new-session" });
344
+ return;
345
+ }
346
+ const scriptPath = new URL("../scripts/sessionizer.sh", import.meta.url).pathname;
347
+ muxCtx.sdk.displayPopup({
348
+ command: `bash "${scriptPath}"`,
349
+ title: " new session ",
350
+ width: "60%",
351
+ height: "60%",
352
+ closeOnExit: true,
353
+ });
354
+ }
355
+
356
+ onMount(() => {
357
+ logResizeDebug("mount", {
358
+ startupSessionName,
359
+ localSessionName: getLocalSessionName(),
360
+ muxType: muxCtx.type,
361
+ tmuxPane: process.env.TMUX_PANE ?? null,
362
+ });
363
+ // Refocus the main pane once terminal capability detection finishes.
364
+ // This avoids the race where the server refocuses too early and capability
365
+ // responses leak as garbage text into the main pane.
366
+ let startupRefocused = false;
367
+ const doStartupRefocus = () => {
368
+ if (startupRefocused) return;
369
+ startupRefocused = true;
370
+ refocusMainPane();
371
+ };
372
+ renderer.on("capabilities", doStartupRefocus);
373
+ // Fallback: if no capability response arrives within 2s, refocus anyway
374
+ const refocusTimeout = setTimeout(doStartupRefocus, 2000);
375
+
376
+ onCleanup(() => {
377
+ clearTimeout(refocusTimeout);
378
+ renderer.removeListener("capabilities", doStartupRefocus);
379
+ });
380
+
381
+ const socket = new WebSocket(`ws://${SERVER_HOST}:${SERVER_PORT}`);
382
+ ws = socket;
383
+
384
+ socket.onopen = () => {
385
+ setConnected(true);
386
+ const tty = clientTty();
387
+ if (tty) send({ type: "identify", clientTty: tty });
388
+ reIdentify();
389
+ };
390
+
391
+ socket.onmessage = (event) => {
392
+ try {
393
+ const msg = JSON.parse(event.data as string) as ServerMessage;
394
+ let startupFocusToPublish: string | null = null;
395
+ batch(() => {
396
+ if (msg.type === "state") {
397
+ const startupFocus =
398
+ !startupFocusSynced &&
399
+ startupSessionName &&
400
+ msg.sessions.some((session) => session.name === startupSessionName)
401
+ ? startupSessionName
402
+ : msg.focusedSession;
403
+
404
+ if (startupFocus === startupSessionName) {
405
+ startupFocusSynced = true;
406
+ if (msg.focusedSession !== startupSessionName) {
407
+ startupFocusToPublish = startupSessionName;
408
+ }
409
+ }
410
+
411
+ setSessions(reconcile(msg.sessions, { key: "name" }));
412
+ setFocusedSession(startupFocus);
413
+ setCurrentSession(msg.currentSession);
414
+ setTheme(resolveTheme(msg.theme));
415
+ } else if (msg.type === "focus") {
416
+ setFocusedSession(msg.focusedSession);
417
+ setCurrentSession(msg.currentSession);
418
+ } else if (msg.type === "your-session") {
419
+ setMySession(msg.name);
420
+ if (msg.clientTty) setClientTty(msg.clientTty);
421
+
422
+ if (!startupFocusSynced && sessions.some((session) => session.name === msg.name)) {
423
+ startupFocusSynced = true;
424
+ setFocusedSession(msg.name);
425
+ if (focusedSession() !== msg.name) {
426
+ startupFocusToPublish = msg.name;
427
+ }
428
+ }
429
+ } else if (msg.type === "re-identify") {
430
+ reIdentify();
431
+ }
432
+ });
433
+
434
+ if (startupFocusToPublish) {
435
+ send({ type: "focus-session", name: startupFocusToPublish });
436
+ }
437
+ } catch {}
438
+ };
439
+
440
+ socket.onclose = () => {
441
+ setConnected(false);
442
+ renderer.destroy();
443
+ };
444
+
445
+ onCleanup(() => socket.close());
446
+
447
+ // Listen for quit messages from server
448
+ socket.addEventListener("message", (event) => {
449
+ try {
450
+ const msg = JSON.parse(event.data as string);
451
+ if (msg.type === "quit") {
452
+ if (ws) ws.close();
453
+ renderer.destroy();
454
+ }
455
+ } catch {}
456
+ });
457
+ });
458
+
459
+ const hasRunning = createMemo(() => sessions.some((s) => s.agentState?.status === "running"));
460
+
461
+ createEffect(() => {
462
+ if (!hasRunning()) return;
463
+ const interval = setInterval(() => {
464
+ setSpinIdx((i) => (i + 1) % SPINNERS.length);
465
+ }, 120);
466
+ onCleanup(() => clearInterval(interval));
467
+ });
468
+
469
+ createEffect(() => {
470
+ const sessionName = detailPanelSessionName();
471
+ if (!sessionName) return;
472
+ const storedHeight = getStoredDetailPanelHeight(sessionName);
473
+ logResizeDebug("loadStoredDetailPanelHeight", {
474
+ session: sessionName,
475
+ storedHeight,
476
+ });
477
+ setDetailPanelHeight(storedHeight);
478
+ });
479
+
480
+ createEffect(() => {
481
+ logResizeDebug("detailPanelHeight:changed", {
482
+ height: detailPanelHeight(),
483
+ session: detailPanelSessionName(),
484
+ isResizing: isDetailResizing(),
485
+ });
486
+ });
487
+
488
+ useKeyboard((key) => {
489
+ const currentModal = modal();
490
+
491
+ // --- Help modal ---
492
+ if (currentModal === "help") {
493
+ setModal("none");
494
+ return;
495
+ }
496
+
497
+ // --- Confirm kill modal ---
498
+ if (currentModal === "confirm-kill") {
499
+ if (key.name === "y") {
500
+ const target = killTarget();
501
+ if (target) send({ type: "kill-session", name: target });
502
+ setKillTarget(null);
503
+ setModal("none");
504
+ } else {
505
+ setKillTarget(null);
506
+ setModal("none");
507
+ }
508
+ return;
509
+ }
510
+
511
+ // --- Normal mode keybindings ---
512
+ // Alt+Up / Alt+Down → reorder session
513
+ if ((key.meta || key.option) && (key.name === "up" || key.name === "down")) {
514
+ const focused = focusedSession();
515
+ if (focused) {
516
+ const delta: -1 | 1 = key.name === "up" ? -1 : 1;
517
+ send({ type: "reorder-session", name: focused, delta });
518
+ }
519
+ return;
520
+ }
521
+
522
+ switch (key.name) {
523
+ case "q":
524
+ send({ type: "quit" });
525
+ break;
526
+ case "escape":
527
+ if (panelFocus() === "agents") {
528
+ setPanelFocus("sessions");
529
+ }
530
+ break;
531
+ case "up":
532
+ case "k":
533
+ if (panelFocus() === "agents") {
534
+ moveAgentFocus(-1);
535
+ } else {
536
+ moveLocalFocus(-1);
537
+ }
538
+ break;
539
+ case "down":
540
+ case "j":
541
+ if (panelFocus() === "agents") {
542
+ moveAgentFocus(1);
543
+ } else {
544
+ moveLocalFocus(1);
545
+ }
546
+ break;
547
+ case "left":
548
+ case "h":
549
+ if (panelFocus() === "agents") {
550
+ setPanelFocus("sessions");
551
+ }
552
+ break;
553
+ case "right":
554
+ case "l": {
555
+ const data = focusedData();
556
+ const agents = data?.agents ?? [];
557
+ if (panelFocus() === "sessions" && agents.length > 0) {
558
+ setPanelFocus("agents");
559
+ setFocusedAgentIdx((idx) => Math.min(idx, agents.length - 1));
560
+ }
561
+ break;
562
+ }
563
+ case "return": {
564
+ if (panelFocus() === "agents") {
565
+ activateFocusedAgent();
566
+ } else {
567
+ const focused = focusedSession();
568
+ if (focused) switchToSession(focused);
569
+ }
570
+ break;
571
+ }
572
+ case "tab": {
573
+ const list = sessions;
574
+ if (list.length === 0) break;
575
+ const cur = currentSession();
576
+ const idx = list.findIndex((s) => s.name === cur);
577
+ const next = list[(idx + (key.shift ? list.length - 1 : 1)) % list.length];
578
+ if (next) switchToSession(next.name);
579
+ break;
580
+ }
581
+ case "r":
582
+ send({ type: "refresh" });
583
+ break;
584
+ case "t":
585
+ // reserved — was theme picker
586
+ break;
587
+ case "u":
588
+ send({ type: "show-all-sessions" });
589
+ break;
590
+ case "d": {
591
+ if (panelFocus() === "agents") {
592
+ dismissFocusedAgent();
593
+ } else {
594
+ const focused = focusedSession();
595
+ if (focused) send({ type: "hide-session", name: focused });
596
+ }
597
+ break;
598
+ }
599
+ case "x": {
600
+ if (panelFocus() === "agents") {
601
+ killFocusedAgentPane();
602
+ } else {
603
+ const focused = focusedSession();
604
+ if (focused) {
605
+ setKillTarget(focused);
606
+ setModal("confirm-kill");
607
+ }
608
+ }
609
+ break;
610
+ }
611
+ case "n":
612
+ case "c":
613
+ createNewSession();
614
+ break;
615
+ case "?":
616
+ setModal("help");
617
+ break;
618
+ default: {
619
+ if (key.number) {
620
+ const idx = Number.parseInt(key.name, 10) - 1;
621
+ const target = sessions[idx];
622
+ if (target) switchToSession(target.name);
623
+ }
624
+ break;
625
+ }
626
+ }
627
+ });
628
+
629
+ const runningAgentCount = createMemo(() =>
630
+ sessions.reduce((n, s) => n + (s.agents?.filter((a) => a.status === "running").length ?? 0), 0),
631
+ );
632
+
633
+ const errorAgentCount = createMemo(() =>
634
+ sessions.reduce((n, s) => n + (s.agents?.filter((a) => a.status === "error").length ?? 0), 0),
635
+ );
636
+
637
+ const unseenCount = createMemo(() => sessions.filter((s) => s.unseen).length);
638
+
639
+ const isFocused = createSelector(focusedSession);
640
+
641
+ return (
642
+ <box flexDirection="column" flexGrow={1} backgroundColor={P().crust}>
643
+ {/* Header */}
644
+ <StatusBar
645
+ sessionCount={sessions.length}
646
+ runningCount={runningAgentCount()}
647
+ errorCount={errorAgentCount()}
648
+ unseenCount={unseenCount()}
649
+ theme={theme}
650
+ />
651
+
652
+ {/* Session list */}
653
+ <scrollbox flexGrow={1} flexShrink={1} paddingTop={1}>
654
+ <For each={sessions}>
655
+ {(session, i) => (
656
+ <SessionCard
657
+ session={session}
658
+ index={i() + 1}
659
+ isFocused={isFocused(session.name)}
660
+ isCurrent={session.name === currentSession()}
661
+ spinIdx={spinIdx}
662
+ theme={theme}
663
+ statusColors={S}
664
+ onSelect={() => {
665
+ setFocusedSession(session.name);
666
+ send({ type: "focus-session", name: session.name });
667
+ switchToSession(session.name);
668
+ }}
669
+ />
670
+ )}
671
+ </For>
672
+ </scrollbox>
673
+
674
+ {/* Detail panel — focused session info, draggable height */}
675
+ <Show when={focusedData()}>
676
+ {(data) => (
677
+ <scrollbox height={detailPanelHeight()} maxHeight={detailPanelHeight()} flexShrink={0}>
678
+ <DetailPanel
679
+ session={data()}
680
+ theme={theme}
681
+ statusColors={S}
682
+ spinIdx={spinIdx}
683
+ focusedAgentIdx={panelFocus() === "agents" ? focusedAgentIdx() : -1}
684
+ onDismissAgent={(agent) => {
685
+ send({
686
+ type: "dismiss-agent",
687
+ session: data().name,
688
+ agent: agent.agent,
689
+ threadId: agent.threadId,
690
+ });
691
+ }}
692
+ onFocusAgentPane={(agent) => {
693
+ if (TUI_DEBUG)
694
+ appendFileSync(
695
+ "/tmp/agentboard-tui-agent-click.log",
696
+ `[${new Date().toISOString()}] sending focus-agent-pane session=${data().name} agent=${agent.agent} threadId=${agent.threadId} threadName=${agent.threadName}\n`,
697
+ );
698
+ send({
699
+ type: "focus-agent-pane",
700
+ session: data().name,
701
+ agent: agent.agent,
702
+ threadId: agent.threadId,
703
+ threadName: agent.threadName,
704
+ });
705
+ }}
706
+ isResizeHover={isDetailResizeHover()}
707
+ isResizing={isDetailResizing()}
708
+ onResizeStart={beginDetailResize}
709
+ onResizeDrag={handleDetailResizeDrag}
710
+ onResizeEnd={endDetailResize}
711
+ onResizeHoverChange={setIsDetailResizeHover}
712
+ />
713
+ </scrollbox>
714
+ )}
715
+ </Show>
716
+
717
+ {/* Footer */}
718
+ <box flexDirection="column" paddingLeft={1} paddingBottom={1} paddingTop={0} flexShrink={0}>
719
+ <box height={1}>
720
+ <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
721
+ </box>
722
+ <Show
723
+ when={panelFocus() === "sessions"}
724
+ fallback={
725
+ <text>
726
+ <span style={{ fg: P().overlay0 }}>{"←"}</span>
727
+ <span style={{ fg: P().overlay1 }}>{" back "}</span>
728
+ <span style={{ fg: P().overlay0 }}>{"⏎"}</span>
729
+ <span style={{ fg: P().overlay1 }}>{" focus "}</span>
730
+ <span style={{ fg: P().overlay0 }}>{"d"}</span>
731
+ <span style={{ fg: P().overlay1 }}>{" dismiss "}</span>
732
+ <span style={{ fg: P().overlay0 }}>{"x"}</span>
733
+ <span style={{ fg: P().overlay1 }}>{" kill"}</span>
734
+ </text>
735
+ }
736
+ >
737
+ <text>
738
+ <span style={{ fg: P().overlay0 }}>{"⇥"}</span>
739
+ <span style={{ fg: P().overlay1 }}>{" cycle "}</span>
740
+ <span style={{ fg: P().overlay0 }}>{"⏎"}</span>
741
+ <span style={{ fg: P().overlay1 }}>{" go "}</span>
742
+ <span style={{ fg: P().overlay0 }}>{"→"}</span>
743
+ <span style={{ fg: P().overlay1 }}>{" detail "}</span>
744
+ <span style={{ fg: P().overlay0 }}>{"d"}</span>
745
+ <span style={{ fg: P().overlay1 }}>{" hide "}</span>
746
+ <span style={{ fg: P().overlay0 }}>{"x"}</span>
747
+ <span style={{ fg: P().overlay1 }}>{" kill"}</span>
748
+ </text>
749
+ </Show>
750
+ </box>
751
+
752
+ {/* Kill confirmation overlay */}
753
+ <Show when={modal() === "confirm-kill"}>
754
+ <box
755
+ position="absolute"
756
+ top={0}
757
+ left={0}
758
+ right={0}
759
+ bottom={0}
760
+ justifyContent="center"
761
+ alignItems="center"
762
+ backgroundColor="transparent"
763
+ >
764
+ <box
765
+ border
766
+ borderStyle="rounded"
767
+ borderColor={P().red}
768
+ backgroundColor={P().mantle}
769
+ padding={1}
770
+ paddingX={2}
771
+ flexDirection="column"
772
+ alignItems="center"
773
+ >
774
+ <text>
775
+ <span style={{ fg: P().red, attributes: BOLD }}>Kill session?</span>
776
+ </text>
777
+ <text>
778
+ <span style={{ fg: P().text }}>{killTarget() ?? ""}</span>
779
+ </text>
780
+ <text>
781
+ <span style={{ fg: P().overlay0 }}>y</span>
782
+ <span style={{ fg: P().overlay1 }}>/</span>
783
+ <span style={{ fg: P().overlay0 }}>n</span>
784
+ </text>
785
+ </box>
786
+ </box>
787
+ </Show>
788
+
789
+ {/* Help overlay */}
790
+ <Show when={modal() === "help"}>
791
+ <HelpOverlay palette={P} onClose={() => setModal("none")} />
792
+ </Show>
793
+ </box>
794
+ );
795
+ }
796
+
797
+ // --- Help Overlay ---
798
+
799
+ function HelpOverlay(props: { palette: Accessor<Theme["palette"]>; onClose: () => void }) {
800
+ const P = () => props.palette();
801
+ const keys: [string, string][] = [
802
+ ["j/k ↑↓", "Move focus"],
803
+ ["Enter", "Switch to session"],
804
+ ["1-9", "Jump to session"],
805
+ ["Tab", "Cycle sessions"],
806
+ ["n/c", "New session"],
807
+ ["d", "Hide session"],
808
+ ["x", "Kill session"],
809
+ ["r", "Refresh"],
810
+ ["u", "Show all sessions"],
811
+ ["→/l", "Detail panel"],
812
+ ["←/h/Esc", "Back to sessions"],
813
+ ["Alt+↑↓", "Reorder sessions"],
814
+ ["q", "Quit"],
815
+ ];
816
+
817
+ return (
818
+ <box
819
+ position="absolute"
820
+ top={0}
821
+ left={0}
822
+ right={0}
823
+ bottom={0}
824
+ justifyContent="center"
825
+ alignItems="center"
826
+ backgroundColor="transparent"
827
+ >
828
+ <box
829
+ border
830
+ borderStyle="rounded"
831
+ borderColor={P().blue}
832
+ backgroundColor={P().mantle}
833
+ padding={1}
834
+ flexDirection="column"
835
+ width={30}
836
+ >
837
+ <text>
838
+ <span style={{ fg: P().blue, attributes: BOLD }}>Keybindings</span>
839
+ </text>
840
+ <box height={1}>
841
+ <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
842
+ </box>
843
+ <For each={keys}>
844
+ {([key, desc]) => (
845
+ <box flexDirection="row" paddingLeft={1}>
846
+ <box width={12} flexShrink={0}>
847
+ <text>
848
+ <span style={{ fg: P().sky }}>{key}</span>
849
+ </text>
850
+ </box>
851
+ <text truncate>
852
+ <span style={{ fg: P().subtext0 }}>{desc}</span>
853
+ </text>
854
+ </box>
855
+ )}
856
+ </For>
857
+ <box height={1}>
858
+ <text style={{ fg: P().surface2 }}>{DIVIDER}</text>
859
+ </box>
860
+ <text style={{ fg: P().overlay0 }}>
861
+ <span style={{ attributes: DIM }}>Press any key to close</span>
862
+ </text>
863
+ </box>
864
+ </box>
865
+ );
866
+ }
867
+
868
+ async function main() {
869
+ await ensureServer();
870
+ render(() => <App />, {
871
+ exitOnCtrlC: true,
872
+ targetFPS: 30,
873
+ useMouse: true,
874
+ });
875
+ }
876
+
877
+ main().catch((err) => {
878
+ console.error(err);
879
+ process.exit(1);
880
+ });