@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,350 @@
1
+ import { createSignal, For, Show } 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
+ SPARK_BLOCKS,
13
+ TONE_ICONS,
14
+ toneColor,
15
+ logResizeDebug,
16
+ } from "../constants";
17
+
18
+ // --- Sparkline ---
19
+
20
+ export function buildSparkline(
21
+ timestamps: number[],
22
+ width: number,
23
+ windowMs: number = 30 * 60 * 1000,
24
+ ): string {
25
+ if (timestamps.length === 0 || width <= 0) return "";
26
+ const now = Date.now();
27
+ const start = now - windowMs;
28
+ const bucketSize = windowMs / width;
29
+ const buckets = Array.from({ length: width }, () => 0);
30
+
31
+ for (const ts of timestamps) {
32
+ if (ts < start) continue;
33
+ const idx = Math.min(width - 1, Math.floor((ts - start) / bucketSize));
34
+ buckets[idx]++;
35
+ }
36
+
37
+ const max = Math.max(...buckets, 1);
38
+ return buckets
39
+ .map((count: number) => {
40
+ const level = Math.round((count / max) * (SPARK_BLOCKS.length - 1));
41
+ return SPARK_BLOCKS[level];
42
+ })
43
+ .join("");
44
+ }
45
+
46
+ // --- Detail Panel ---
47
+
48
+ export interface DetailPanelProps {
49
+ session: SessionData;
50
+ theme: Accessor<Theme>;
51
+ statusColors: Accessor<Theme["status"]>;
52
+ spinIdx: Accessor<number>;
53
+ focusedAgentIdx: number;
54
+ onDismissAgent: (agent: SessionData["agents"][number]) => void;
55
+ onFocusAgentPane: (agent: SessionData["agents"][number]) => void;
56
+ isResizeHover: boolean;
57
+ isResizing: boolean;
58
+ onResizeStart: (event: MouseEvent) => void;
59
+ onResizeDrag: (event: MouseEvent) => void;
60
+ onResizeEnd: (event?: MouseEvent) => void;
61
+ onResizeHoverChange: (hovered: boolean) => void;
62
+ }
63
+
64
+ export function DetailPanel(props: DetailPanelProps) {
65
+ const P = () => props.theme().palette;
66
+
67
+ const agents = () => props.session.agents ?? [];
68
+ const hasAgents = () => agents().length > 0;
69
+ const meta = () => props.session.metadata;
70
+ const hasMeta = () => !!meta();
71
+ const visibleLogs = () => {
72
+ const m = meta();
73
+ if (!m || m.logs.length === 0) return [];
74
+ return m.logs.slice(-8);
75
+ };
76
+
77
+ const truncDir = () => {
78
+ const d = props.session.dir;
79
+ if (!d) return "";
80
+ const home = process.env.HOME ?? "";
81
+ const short = home && d.startsWith(home) ? "~" + d.slice(home.length) : d;
82
+ return short.length > 24 ? "…" + short.slice(short.length - 23) : short;
83
+ };
84
+
85
+ return (
86
+ <box flexDirection="column" flexShrink={0} paddingLeft={1}>
87
+ <box height={1}>
88
+ <text
89
+ selectable={false}
90
+ onMouseDown={(event) => {
91
+ logResizeDebug("separator:onMouseDown", {
92
+ x: event.x,
93
+ y: event.y,
94
+ button: event.button,
95
+ session: props.session.name,
96
+ });
97
+ event.preventDefault();
98
+ props.onResizeStart(event);
99
+ }}
100
+ onMouseDrag={(event) => {
101
+ logResizeDebug("separator:onMouseDrag", {
102
+ x: event.x,
103
+ y: event.y,
104
+ button: event.button,
105
+ session: props.session.name,
106
+ });
107
+ event.preventDefault();
108
+ props.onResizeDrag(event);
109
+ }}
110
+ onMouseDragEnd={(event) => {
111
+ logResizeDebug("separator:onMouseDragEnd", {
112
+ x: event.x,
113
+ y: event.y,
114
+ button: event.button,
115
+ session: props.session.name,
116
+ });
117
+ event.preventDefault();
118
+ props.onResizeEnd(event);
119
+ }}
120
+ onMouseUp={(event) => {
121
+ logResizeDebug("separator:onMouseUp", {
122
+ x: event.x,
123
+ y: event.y,
124
+ button: event.button,
125
+ session: props.session.name,
126
+ });
127
+ event.preventDefault();
128
+ props.onResizeEnd(event);
129
+ }}
130
+ onMouseOver={() => props.onResizeHoverChange(true)}
131
+ onMouseOut={() => {
132
+ if (!props.isResizing) props.onResizeHoverChange(false);
133
+ }}
134
+ style={{
135
+ fg: props.isResizing ? P().blue : props.isResizeHover ? P().overlay1 : P().surface2,
136
+ }}
137
+ >
138
+ {"─".repeat(200)}
139
+ </text>
140
+ </box>
141
+
142
+ {/* Directory */}
143
+ <text truncate>
144
+ <span style={{ fg: P().overlay0, attributes: DIM }}>{truncDir()}</span>
145
+ </text>
146
+
147
+ {/* Agent instances */}
148
+ <Show when={hasAgents()}>
149
+ <For each={agents()}>
150
+ {(agent, i) => (
151
+ <AgentListItem
152
+ agent={agent}
153
+ palette={P}
154
+ statusColors={props.statusColors}
155
+ spinIdx={props.spinIdx}
156
+ isKeyboardFocused={i() === props.focusedAgentIdx}
157
+ onDismiss={() => props.onDismissAgent(agent)}
158
+ onFocusPane={() => props.onFocusAgentPane(agent)}
159
+ />
160
+ )}
161
+ </For>
162
+ </Show>
163
+
164
+ {/* Metadata: status, progress, logs */}
165
+ <Show when={hasMeta()}>
166
+ {(_) => {
167
+ const m = meta()!;
168
+ const progressText = () => {
169
+ const p = m.progress;
170
+ if (!p) return "";
171
+ if (p.current != null && p.total != null) return `${p.current}/${p.total}`;
172
+ if (p.percent != null) return `${Math.round(p.percent * 100)}%`;
173
+ return "";
174
+ };
175
+ return (
176
+ <box flexDirection="column">
177
+ <box height={1} />
178
+
179
+ {/* Status + progress on one line */}
180
+ <Show when={m.status || m.progress}>
181
+ <box flexDirection="row" paddingRight={1}>
182
+ <Show when={m.status}>
183
+ <text truncate flexGrow={1}>
184
+ <span style={{ fg: toneColor(m.status!.tone, P()) }}>
185
+ {TONE_ICONS[m.status!.tone ?? "neutral"]} {m.status!.text}
186
+ </span>
187
+ </text>
188
+ </Show>
189
+ <Show when={m.progress}>
190
+ <text flexShrink={0}>
191
+ <span style={{ fg: P().sky }}>
192
+ {m.status ? " · " : ""}
193
+ {progressText()}
194
+ {m.progress!.label ? ` ${m.progress!.label}` : ""}
195
+ </span>
196
+ </text>
197
+ </Show>
198
+ </box>
199
+ </Show>
200
+
201
+ {/* Log entries */}
202
+ <Show when={visibleLogs().length > 0}>
203
+ <For each={visibleLogs()}>
204
+ {(entry) => (
205
+ <text truncate>
206
+ <span style={{ fg: toneColor(entry.tone, P()), attributes: DIM }}>
207
+ {TONE_ICONS[entry.tone ?? "neutral"]}
208
+ </span>
209
+ <Show when={entry.source}>
210
+ <span
211
+ style={{ fg: P().surface2, attributes: DIM }}
212
+ >{` [${entry.source}]`}</span>
213
+ </Show>
214
+ <span style={{ fg: P().overlay0 }}> {entry.message}</span>
215
+ </text>
216
+ )}
217
+ </For>
218
+ </Show>
219
+ </box>
220
+ );
221
+ }}
222
+ </Show>
223
+ </box>
224
+ );
225
+ }
226
+
227
+ // --- Agent List Item ---
228
+
229
+ interface AgentListItemProps {
230
+ agent: SessionData["agents"][number];
231
+ palette: Accessor<Theme["palette"]>;
232
+ statusColors: Accessor<Theme["status"]>;
233
+ spinIdx: Accessor<number>;
234
+ isKeyboardFocused: boolean;
235
+ onDismiss: () => void;
236
+ onFocusPane: () => void;
237
+ }
238
+
239
+ function AgentListItem(props: AgentListItemProps) {
240
+ const P = () => props.palette();
241
+ const SC = () => props.statusColors();
242
+ const [isDismissHover, setIsDismissHover] = createSignal(false);
243
+ const [isFlash, setIsFlash] = createSignal(false);
244
+
245
+ const isTerminal = () => ["done", "error", "interrupted"].includes(props.agent.status);
246
+ const isUnseen = () => isTerminal() && props.agent.unseen === true;
247
+
248
+ const icon = () => {
249
+ if (isUnseen()) return UNSEEN_ICON;
250
+ if (isTerminal())
251
+ return props.agent.status === "done" ? "✓" : props.agent.status === "error" ? "✗" : "⚠";
252
+ if (props.agent.status === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
253
+ if (props.agent.status === "waiting") return "◉";
254
+ return "○";
255
+ };
256
+
257
+ const color = () => {
258
+ if (isTerminal()) {
259
+ if (props.agent.status === "error") return P().red;
260
+ if (props.agent.status === "interrupted") return P().peach;
261
+ return isUnseen() ? P().teal : P().green;
262
+ }
263
+ return SC()[props.agent.status];
264
+ };
265
+
266
+ const statusText = () => {
267
+ if (props.agent.status === "running") return "running";
268
+ if (props.agent.status === "done") return "done";
269
+ if (props.agent.status === "error") return "error";
270
+ if (props.agent.status === "interrupted") return "stopped";
271
+ if (props.agent.status === "waiting") return "waiting";
272
+ return "";
273
+ };
274
+
275
+ const triggerFlash = () => {
276
+ setIsFlash(true);
277
+ setTimeout(() => setIsFlash(false), 150);
278
+ };
279
+
280
+ const bgColor = () => {
281
+ if (isFlash()) return P().surface1;
282
+ if (props.isKeyboardFocused) return P().surface0;
283
+ return "transparent";
284
+ };
285
+
286
+ return (
287
+ <box
288
+ flexDirection="column"
289
+ flexShrink={0}
290
+ onMouseDown={(event) => {
291
+ // Don't trigger focus if clicking the dismiss button
292
+ if ((event.target as any)?.id === "dismiss") return;
293
+ appendFileSync(
294
+ TUI_AGENT_CLICK_LOG,
295
+ `[${new Date().toISOString()}] clicked agent=${props.agent.agent} thread=${props.agent.threadName ?? "?"}\n`,
296
+ );
297
+ triggerFlash();
298
+ props.onFocusPane();
299
+ }}
300
+ >
301
+ <box height={1} />
302
+ <box flexDirection="row" backgroundColor={bgColor()} paddingLeft={1}>
303
+ {/* Content column — name row + thread name row */}
304
+ <box flexDirection="column" flexGrow={1} paddingRight={1}>
305
+ {/* Row 1: icon + agent name + status + dismiss */}
306
+ <box flexDirection="row">
307
+ <text flexGrow={1} truncate>
308
+ <span style={{ fg: color() }}>{icon()}</span>
309
+ <span
310
+ style={{
311
+ fg: props.isKeyboardFocused ? P().text : P().subtext1,
312
+ attributes: props.isKeyboardFocused ? BOLD : undefined,
313
+ }}
314
+ >
315
+ {" "}
316
+ {props.agent.agent}
317
+ </span>
318
+ </text>
319
+ <Show when={!isTerminal() || !isUnseen()}>
320
+ <text flexShrink={0}>
321
+ <span style={{ fg: color(), attributes: DIM }}>{statusText()}</span>
322
+ </text>
323
+ </Show>
324
+ <text
325
+ flexShrink={0}
326
+ onMouseDown={(event) => {
327
+ event.preventDefault();
328
+ event.stopPropagation();
329
+ props.onDismiss();
330
+ }}
331
+ onMouseOver={() => setIsDismissHover(true)}
332
+ onMouseOut={() => setIsDismissHover(false)}
333
+ >
334
+ <span style={{ fg: isDismissHover() ? P().red : P().overlay0 }}>{" ✕"}</span>
335
+ </text>
336
+ </box>
337
+
338
+ {/* Row 2: thread name */}
339
+ <Show when={props.agent.threadName}>
340
+ <text truncate>
341
+ <span style={{ fg: isUnseen() ? color() : P().overlay0 }}>
342
+ {props.agent.threadName}
343
+ </span>
344
+ </text>
345
+ </Show>
346
+ </box>
347
+ </box>
348
+ </box>
349
+ );
350
+ }
@@ -0,0 +1,33 @@
1
+ import { Show } from "solid-js";
2
+ import type { Accessor } from "solid-js";
3
+ import type { SessionData, Theme } from "@tt-agentboard/runtime";
4
+
5
+ export interface DiffStatsProps {
6
+ session: SessionData;
7
+ palette: Accessor<Theme["palette"]>;
8
+ }
9
+
10
+ export function DiffStats(props: DiffStatsProps) {
11
+ const P = () => props.palette();
12
+ const s = () => props.session;
13
+
14
+ return (
15
+ <text flexShrink={0}>
16
+ <Show when={s().filesChanged}>
17
+ <span style={{ fg: P().overlay0 }}>{s().filesChanged}f </span>
18
+ </Show>
19
+ <Show when={s().linesAdded}>
20
+ <span style={{ fg: P().green }}>+{s().linesAdded} </span>
21
+ </Show>
22
+ <Show when={s().linesRemoved}>
23
+ <span style={{ fg: P().red }}>-{s().linesRemoved} </span>
24
+ </Show>
25
+ <Show when={s().commitsDelta > 0}>
26
+ <span style={{ fg: P().sky }}>{s().commitsDelta}↑</span>
27
+ </Show>
28
+ <Show when={s().commitsDelta < 0}>
29
+ <span style={{ fg: P().peach }}>{Math.abs(s().commitsDelta)}↓</span>
30
+ </Show>
31
+ </text>
32
+ );
33
+ }
@@ -0,0 +1,177 @@
1
+ import { Show } from "solid-js";
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";
5
+ import { DiffStats } from "./DiffStats";
6
+
7
+ export interface SessionCardProps {
8
+ session: SessionData;
9
+ index: number;
10
+ isFocused: boolean;
11
+ isCurrent: boolean;
12
+ spinIdx: Accessor<number>;
13
+ theme: Accessor<Theme>;
14
+ statusColors: Accessor<Theme["status"]>;
15
+ onSelect: () => void;
16
+ }
17
+
18
+ export function SessionCard(props: SessionCardProps) {
19
+ const P = () => props.theme().palette;
20
+ const SC = () => props.statusColors();
21
+
22
+ const status = () => props.session.agentState?.status ?? "idle";
23
+ const unseen = () => props.session.unseen;
24
+ const runningAgents = () =>
25
+ props.session.agents?.filter((a) => a.status === "running").length ?? 0;
26
+
27
+ const isUnseenTerminal = () => unseen() && ["done", "error", "interrupted"].includes(status());
28
+
29
+ const accentColor = () => {
30
+ if (props.isCurrent) return P().green;
31
+ if (isUnseenTerminal()) return unseenAccentColor();
32
+ const s = status();
33
+ if (s === "error") return P().red;
34
+ if (s === "interrupted") return P().peach;
35
+ if (s === "running") return P().yellow;
36
+ if (props.isFocused) return P().lavender;
37
+ return "transparent";
38
+ };
39
+
40
+ const unseenAccentColor = () => {
41
+ const s = status();
42
+ if (s === "error") return P().red;
43
+ if (s === "interrupted") return P().peach;
44
+ return P().teal;
45
+ };
46
+
47
+ const statusIcon = () => {
48
+ const s = status();
49
+ if (s === "running") return SPINNERS[props.spinIdx() % SPINNERS.length]!;
50
+ if (isUnseenTerminal()) return UNSEEN_ICON;
51
+ return "";
52
+ };
53
+
54
+ const statusColor = () => {
55
+ if (isUnseenTerminal()) return unseenAccentColor();
56
+ return SC()[status()];
57
+ };
58
+
59
+ const nameColor = () => {
60
+ if (props.isFocused) return P().text;
61
+ if (props.isCurrent) return P().subtext1;
62
+ return P().subtext0;
63
+ };
64
+
65
+ const indexColor = () => {
66
+ if (props.isFocused) return P().subtext0;
67
+ return P().surface2;
68
+ };
69
+
70
+ const truncName = () => {
71
+ const n = props.session.name;
72
+ return n.length > 18 ? n.slice(0, 17) + "…" : n;
73
+ };
74
+
75
+ const truncBranch = () => {
76
+ const b = props.session.branch;
77
+ if (!b) return "";
78
+ return b.length > 30 ? b.slice(0, 29) + "…" : b;
79
+ };
80
+
81
+ const hasDiff = () => {
82
+ const { linesAdded, linesRemoved, commitsDelta, filesChanged } = props.session;
83
+ return !!(linesAdded || linesRemoved || commitsDelta || filesChanged);
84
+ };
85
+
86
+ const metaSummary = () => {
87
+ const meta = props.session.metadata;
88
+ if (!meta) return "";
89
+ const parts: string[] = [];
90
+ if (meta.status) parts.push(meta.status.text);
91
+ if (meta.progress) {
92
+ if (meta.progress.current != null && meta.progress.total != null) {
93
+ parts.push(`${meta.progress.current}/${meta.progress.total}`);
94
+ } else if (meta.progress.percent != null) {
95
+ parts.push(`${Math.round(meta.progress.percent * 100)}%`);
96
+ }
97
+ if (meta.progress.label) parts.push(meta.progress.label);
98
+ }
99
+ return parts.join(" · ");
100
+ };
101
+
102
+ const metaTone = () => props.session.metadata?.status?.tone;
103
+
104
+ const bgColor = () => {
105
+ if (props.isFocused) return P().surface1;
106
+ return "transparent";
107
+ };
108
+
109
+ return (
110
+ <box flexDirection="column" flexShrink={0}>
111
+ <box
112
+ flexDirection="row"
113
+ backgroundColor={bgColor()}
114
+ onMouseDown={props.onSelect}
115
+ paddingLeft={1}
116
+ >
117
+ {/* Left accent — space-preserving, only colored for meaningful states */}
118
+ <text style={{ fg: accentColor() }}>{accentColor() === "transparent" ? " " : "▌"}</text>
119
+
120
+ {/* Index */}
121
+ <box width={3} flexShrink={0}>
122
+ <text style={{ fg: indexColor() }}>{String(props.index).padStart(2)}</text>
123
+ </box>
124
+
125
+ {/* Content */}
126
+ <box flexDirection="column" flexGrow={1} paddingRight={1}>
127
+ {/* Row 1: name + status */}
128
+ <box flexDirection="row">
129
+ <text truncate flexGrow={1}>
130
+ <span
131
+ style={{
132
+ fg: nameColor(),
133
+ attributes: props.isFocused || props.isCurrent ? BOLD : undefined,
134
+ }}
135
+ >
136
+ {truncName()}
137
+ </span>
138
+ </text>
139
+ <Show when={statusIcon()}>
140
+ <text flexShrink={0}>
141
+ <span style={{ fg: statusColor() }}>
142
+ {" "}
143
+ {statusIcon()}
144
+ {runningAgents() > 1 ? String(runningAgents()) : ""}
145
+ </span>
146
+ </text>
147
+ </Show>
148
+ </box>
149
+
150
+ {/* Row 2: branch */}
151
+ <Show when={props.session.branch}>
152
+ <text truncate>
153
+ <span style={{ fg: props.isFocused ? P().pink : P().overlay0 }}>{truncBranch()}</span>
154
+ </text>
155
+ </Show>
156
+
157
+ {/* Row 3: git diff stats */}
158
+ <Show when={hasDiff()}>
159
+ <DiffStats session={props.session} palette={() => P()} />
160
+ </Show>
161
+
162
+ {/* Row 3: metadata summary (status + progress) */}
163
+ <Show when={metaSummary()}>
164
+ <text truncate>
165
+ <span style={{ fg: toneColor(metaTone(), P()), attributes: DIM }}>
166
+ {metaSummary()}
167
+ </span>
168
+ </text>
169
+ </Show>
170
+ </box>
171
+ </box>
172
+
173
+ {/* Breathing room — 1 empty line between cards */}
174
+ <box height={1} />
175
+ </box>
176
+ );
177
+ }
@@ -0,0 +1,49 @@
1
+ import { Show } from "solid-js";
2
+ import type { Accessor } from "solid-js";
3
+ import type { Theme } from "@tt-agentboard/runtime";
4
+ import { BOLD } from "../constants";
5
+
6
+ export interface StatusBarProps {
7
+ sessionCount: number;
8
+ runningCount: number;
9
+ errorCount: number;
10
+ unseenCount: number;
11
+ theme: Accessor<Theme>;
12
+ }
13
+
14
+ export function StatusBar(props: StatusBarProps) {
15
+ const P = () => props.theme().palette;
16
+
17
+ return (
18
+ <box flexDirection="column" paddingLeft={1} paddingTop={1} paddingBottom={0} flexShrink={0}>
19
+ <text>
20
+ <span style={{ fg: P().mauve, attributes: BOLD }}>{" AgentBoard"}</span>
21
+ </text>
22
+ <text>
23
+ <span style={{ fg: P().overlay1 }}>{" "}</span>
24
+ <span style={{ fg: P().overlay0 }}>{props.sessionCount}s</span>
25
+ <Show when={props.runningCount > 0}>
26
+ <span style={{ fg: P().yellow }}>
27
+ {" "}
28
+ {"⚡"}
29
+ {props.runningCount}
30
+ </span>
31
+ </Show>
32
+ <Show when={props.errorCount > 0}>
33
+ <span style={{ fg: P().red }}>
34
+ {" "}
35
+ {"✗"}
36
+ {props.errorCount}
37
+ </span>
38
+ </Show>
39
+ <Show when={props.unseenCount > 0}>
40
+ <span style={{ fg: P().teal }}>
41
+ {" "}
42
+ {"●"}
43
+ {props.unseenCount}
44
+ </span>
45
+ </Show>
46
+ </text>
47
+ </box>
48
+ );
49
+ }
@@ -0,0 +1,46 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { BUILTIN_THEMES, TUI_RESIZE_LOG } from "@tt-agentboard/runtime";
3
+ import type { Theme, MetadataTone } from "@tt-agentboard/runtime";
4
+ import { appendFileSync } from "node:fs";
5
+
6
+ export const SPINNERS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ export const UNSEEN_ICON = "●";
8
+ export const BOLD = TextAttributes.BOLD;
9
+ export const DIM = TextAttributes.DIM;
10
+ export const SPARK_BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
11
+
12
+ export const THEME_NAMES = Object.keys(BUILTIN_THEMES);
13
+ export const DEFAULT_DETAIL_PANEL_HEIGHT = 10;
14
+ export const MIN_DETAIL_PANEL_HEIGHT = 4;
15
+ export { TUI_RESIZE_LOG };
16
+
17
+ export const TONE_ICONS: Record<MetadataTone, string> = {
18
+ neutral: "·",
19
+ info: "ℹ",
20
+ success: "✓",
21
+ warn: "⚠",
22
+ error: "✗",
23
+ };
24
+
25
+ export function toneColor(tone: MetadataTone | undefined, palette: Theme["palette"]): string {
26
+ switch (tone) {
27
+ case "success":
28
+ return palette.green;
29
+ case "error":
30
+ return palette.red;
31
+ case "warn":
32
+ return palette.yellow;
33
+ case "info":
34
+ return palette.blue;
35
+ default:
36
+ return palette.overlay0;
37
+ }
38
+ }
39
+
40
+ export function logResizeDebug(message: string, data?: Record<string, unknown>): void {
41
+ const ts = new Date().toISOString();
42
+ const extra = data ? ` ${JSON.stringify(data)}` : "";
43
+ try {
44
+ appendFileSync(TUI_RESIZE_LOG, `[${ts}] [pid:${process.pid}] ${message}${extra}\n`);
45
+ } catch {}
46
+ }
@@ -0,0 +1,21 @@
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
+ }