tuiboard 0.11.0 → 0.13.0
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/.tuiboard/config.example.yaml +33 -9
- package/CHANGELOG.md +127 -0
- package/README.md +116 -30
- package/bin/tuiboard.ts +9 -0
- package/package.json +3 -2
- package/src/app-exit.ts +37 -0
- package/src/app.tsx +23 -13
- package/src/config/loader.ts +40 -7
- package/src/input/handleKey.ts +115 -52
- package/src/input/herdr-open.ts +132 -0
- package/src/input/open-session.ts +452 -0
- package/src/store/agent-adapters/claude-code.ts +349 -0
- package/src/store/agent-adapters/codex.ts +406 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +196 -0
- package/src/store/agent-adapters/pi.ts +254 -0
- package/src/store/agents.ts +182 -342
- package/src/store/herdr.ts +272 -0
- package/src/store/index.ts +48 -8
- package/src/ui/AgentRow.tsx +188 -50
- package/src/ui/AgentsBar.tsx +9 -3
- package/src/ui/Modal.tsx +63 -6
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +12 -5
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdr (terminal workspace manager for coding agents) link.
|
|
3
|
+
*
|
|
4
|
+
* `herdr api snapshot` lists every pane with the agent running in it, that
|
|
5
|
+
* agent's state and — when herdr's integration for that agent is installed —
|
|
6
|
+
* the agent session it's on (`{kind: "id"}` for Claude/OpenCode, `{kind:
|
|
7
|
+
* "path"}` for Pi). tuiboard polls it and links panes to its own sessions, so
|
|
8
|
+
* a session open in herdr shows herdr's live state (including "idle" and
|
|
9
|
+
* "waiting for you", which the on-disk formats can't tell) and where it lives.
|
|
10
|
+
*
|
|
11
|
+
* Optional by design: no `herdr` on PATH, no server, or an unexpected answer
|
|
12
|
+
* all just mean "no links".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentProvider, AgentSession, AgentStatus } from "~/store/agents";
|
|
16
|
+
|
|
17
|
+
export type HerdrAgentStatus = "idle" | "working" | "blocked" | "done" | "unknown";
|
|
18
|
+
|
|
19
|
+
export interface HerdrPane {
|
|
20
|
+
paneId: string;
|
|
21
|
+
tabId: string;
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
agent?: string;
|
|
24
|
+
status: HerdrAgentStatus;
|
|
25
|
+
cwd: string;
|
|
26
|
+
session?: { kind: "id" | "path"; value: string };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HerdrSnapshot {
|
|
30
|
+
panes: HerdrPane[];
|
|
31
|
+
focusedWorkspaceId?: string;
|
|
32
|
+
tabs: Map<string, { label: string; number: number }>;
|
|
33
|
+
workspaces: Map<string, { label: string; number: number }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `herdr` binary to drive: the one herdr itself advertises, else PATH. */
|
|
37
|
+
export function herdrBin(env: Record<string, string | undefined> = process.env): string | undefined {
|
|
38
|
+
return env.HERDR_BIN_PATH || Bun.which("herdr") || undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where a session is open in herdr. */
|
|
42
|
+
export interface HerdrLink {
|
|
43
|
+
paneId: string;
|
|
44
|
+
tabId: string;
|
|
45
|
+
workspaceId: string;
|
|
46
|
+
workspaceLabel: string;
|
|
47
|
+
tabLabel: string;
|
|
48
|
+
tabNumber: number;
|
|
49
|
+
status: HerdrAgentStatus;
|
|
50
|
+
/** How the pane was matched; `cwd` = no session identity from herdr. */
|
|
51
|
+
matchedBy: "id" | "path" | "cwd";
|
|
52
|
+
/** herdr's name for the agent (for `herdr integration install <name>`). */
|
|
53
|
+
agent: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `herdr blits · tab 3` */
|
|
57
|
+
export function herdrPlace(l: HerdrLink): string {
|
|
58
|
+
return `herdr ${l.workspaceLabel || l.workspaceId} · tab ${l.tabNumber || l.tabId}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** herdr agent name ↔ tuiboard provider. */
|
|
62
|
+
export const HERDR_AGENT: Record<AgentProvider, string> = {
|
|
63
|
+
"claude-code": "claude",
|
|
64
|
+
codex: "codex",
|
|
65
|
+
opencode: "opencode",
|
|
66
|
+
pi: "pi",
|
|
67
|
+
};
|
|
68
|
+
const PROVIDER_OF: Record<string, AgentProvider> = Object.fromEntries(
|
|
69
|
+
Object.entries(HERDR_AGENT).map(([p, a]) => [a, p as AgentProvider]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const STATUSES = new Set<HerdrAgentStatus>(["idle", "working", "blocked", "done", "unknown"]);
|
|
73
|
+
|
|
74
|
+
/** Parse `herdr api snapshot` output. Returns undefined if it isn't one. */
|
|
75
|
+
export function parseHerdrSnapshot(raw: string): HerdrSnapshot | undefined {
|
|
76
|
+
let data: any;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const snap = data?.result?.snapshot ?? data?.snapshot;
|
|
83
|
+
if (!snap || !Array.isArray(snap.panes)) return undefined;
|
|
84
|
+
const panes: HerdrPane[] = [];
|
|
85
|
+
for (const p of snap.panes) {
|
|
86
|
+
if (typeof p?.pane_id !== "string") continue;
|
|
87
|
+
const s = p.agent_session;
|
|
88
|
+
panes.push({
|
|
89
|
+
paneId: p.pane_id,
|
|
90
|
+
tabId: String(p.tab_id ?? ""),
|
|
91
|
+
workspaceId: String(p.workspace_id ?? ""),
|
|
92
|
+
agent: typeof p.agent === "string" ? p.agent : undefined,
|
|
93
|
+
status: STATUSES.has(p.agent_status) ? p.agent_status : "unknown",
|
|
94
|
+
cwd: String(p.cwd ?? ""),
|
|
95
|
+
session:
|
|
96
|
+
s && (s.kind === "id" || s.kind === "path") && typeof s.value === "string"
|
|
97
|
+
? { kind: s.kind, value: s.value }
|
|
98
|
+
: undefined,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const byId = (list: unknown, key: string) =>
|
|
102
|
+
new Map<string, { label: string; number: number }>(
|
|
103
|
+
(Array.isArray(list) ? list : [])
|
|
104
|
+
.filter((x: any) => typeof x?.[key] === "string")
|
|
105
|
+
.map((x: any) => [x[key], { label: String(x.label ?? ""), number: Number(x.number ?? 0) }]),
|
|
106
|
+
);
|
|
107
|
+
return {
|
|
108
|
+
panes,
|
|
109
|
+
focusedWorkspaceId: typeof snap.focused_workspace_id === "string" ? snap.focused_workspace_id : undefined,
|
|
110
|
+
tabs: byId(snap.tabs, "tab_id"),
|
|
111
|
+
workspaces: byId(snap.workspaces, "workspace_id"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Compare paths across the formats agents store (`C:/x`, `C:\x`, lossy case). */
|
|
116
|
+
export function samePath(a: string, b: string): boolean {
|
|
117
|
+
const norm = (p: string) => {
|
|
118
|
+
let s = p.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
119
|
+
if (/^[A-Za-z]:/.test(s)) s = s.toLowerCase();
|
|
120
|
+
return s;
|
|
121
|
+
};
|
|
122
|
+
if (!a || !b) return false;
|
|
123
|
+
const x = norm(a);
|
|
124
|
+
const y = norm(b);
|
|
125
|
+
return x === y || x.toLowerCase() === y.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const STATUS_FROM_HERDR: Partial<Record<HerdrAgentStatus, AgentStatus>> = {
|
|
129
|
+
working: "live-busy",
|
|
130
|
+
blocked: "live-blocked",
|
|
131
|
+
done: "live-done",
|
|
132
|
+
idle: "live-idle",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Attach herdr links to sessions and let herdr's state win where it knows
|
|
137
|
+
* it. Panes reporting a session id/path are matched first; panes without one
|
|
138
|
+
* fall back to "most recent session of that agent in that directory".
|
|
139
|
+
*/
|
|
140
|
+
export function linkHerdrSessions(
|
|
141
|
+
sessions: AgentSession[],
|
|
142
|
+
snap: HerdrSnapshot | undefined,
|
|
143
|
+
): AgentSession[] {
|
|
144
|
+
if (!snap || snap.panes.length === 0) return sessions;
|
|
145
|
+
const links = new Map<AgentSession, HerdrLink>();
|
|
146
|
+
const link = (s: AgentSession, p: HerdrPane, matchedBy: HerdrLink["matchedBy"]) => {
|
|
147
|
+
const tab = snap.tabs.get(p.tabId);
|
|
148
|
+
links.set(s, {
|
|
149
|
+
paneId: p.paneId,
|
|
150
|
+
tabId: p.tabId,
|
|
151
|
+
workspaceId: p.workspaceId,
|
|
152
|
+
workspaceLabel: snap.workspaces.get(p.workspaceId)?.label ?? "",
|
|
153
|
+
tabLabel: tab?.label ?? "",
|
|
154
|
+
tabNumber: tab?.number ?? 0,
|
|
155
|
+
status: p.status,
|
|
156
|
+
matchedBy,
|
|
157
|
+
agent: p.agent ?? "",
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const agentPanes = snap.panes.filter((p) => p.agent && PROVIDER_OF[p.agent]);
|
|
161
|
+
|
|
162
|
+
for (const p of agentPanes) {
|
|
163
|
+
if (!p.session) continue;
|
|
164
|
+
const provider = PROVIDER_OF[p.agent!];
|
|
165
|
+
const { kind, value } = p.session;
|
|
166
|
+
const s = sessions.find(
|
|
167
|
+
(x) =>
|
|
168
|
+
x.provider === provider &&
|
|
169
|
+
!links.has(x) &&
|
|
170
|
+
(kind === "id"
|
|
171
|
+
? x.sessionId === value
|
|
172
|
+
: samePath(x.sourcePath, value) || value.includes(x.sessionId)),
|
|
173
|
+
);
|
|
174
|
+
if (s) link(s, p, kind);
|
|
175
|
+
}
|
|
176
|
+
for (const p of agentPanes) {
|
|
177
|
+
if (p.session) continue;
|
|
178
|
+
const provider = PROVIDER_OF[p.agent!];
|
|
179
|
+
const s = sessions
|
|
180
|
+
.filter((x) => x.provider === provider && !links.has(x) && samePath(x.cwd, p.cwd))
|
|
181
|
+
.sort((a, b) => b.lastActivityMs - a.lastActivityMs)[0];
|
|
182
|
+
if (s) link(s, p, "cwd");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (links.size === 0) return sessions;
|
|
186
|
+
return sessions.map((s) => {
|
|
187
|
+
const l = links.get(s);
|
|
188
|
+
if (!l) return s;
|
|
189
|
+
return { ...s, herdr: l, status: STATUS_FROM_HERDR[l.status] ?? s.status };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Agent status glyph style — the same two herdr offers. */
|
|
194
|
+
export type IndicatorStyle = "dots" | "symbols";
|
|
195
|
+
|
|
196
|
+
/** One synchronous snapshot, for CLI/dev scripts. */
|
|
197
|
+
export function readHerdrSnapshotOnce(bin = Bun.which("herdr") ?? undefined): HerdrSnapshot | undefined {
|
|
198
|
+
if (!bin) return undefined;
|
|
199
|
+
try {
|
|
200
|
+
const res = Bun.spawnSync([bin, "api", "snapshot"], { stdout: "pipe", stderr: "ignore", timeout: 5000 });
|
|
201
|
+
return res.exitCode === 0 ? parseHerdrSnapshot(res.stdout.toString()) : undefined;
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Polling source ─────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export interface HerdrSource {
|
|
210
|
+
snapshot: () => HerdrSnapshot | undefined;
|
|
211
|
+
onChange: (cb: () => void) => void;
|
|
212
|
+
dispose: () => void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const HERDR_POLL_MS = 2000;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Poll `herdr api snapshot` in the background. Inert when herdr isn't on
|
|
219
|
+
* PATH (or `bin: null`). A failing call clears the snapshot (server gone)
|
|
220
|
+
* without noise.
|
|
221
|
+
*/
|
|
222
|
+
export function createHerdrSource(
|
|
223
|
+
{ pollMs = HERDR_POLL_MS, bin = Bun.which("herdr") }: { pollMs?: number; bin?: string | null } = {},
|
|
224
|
+
): HerdrSource {
|
|
225
|
+
let current: HerdrSnapshot | undefined;
|
|
226
|
+
let lastRaw = "";
|
|
227
|
+
const listeners: (() => void)[] = [];
|
|
228
|
+
if (!bin) return { snapshot: () => undefined, onChange: () => {}, dispose: () => {} };
|
|
229
|
+
|
|
230
|
+
let inFlight = false;
|
|
231
|
+
let disposed = false;
|
|
232
|
+
const poll = async () => {
|
|
233
|
+
if (inFlight || disposed) return;
|
|
234
|
+
inFlight = true;
|
|
235
|
+
try {
|
|
236
|
+
const proc = Bun.spawn([bin, "api", "snapshot"], {
|
|
237
|
+
stdout: "pipe",
|
|
238
|
+
stderr: "ignore",
|
|
239
|
+
windowsHide: true,
|
|
240
|
+
});
|
|
241
|
+
const timer = setTimeout(() => proc.kill(), 5000);
|
|
242
|
+
const out = await new Response(proc.stdout).text();
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
const code = await proc.exited;
|
|
245
|
+
const snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
|
|
246
|
+
// Only the parts we use decide whether anything changed.
|
|
247
|
+
const key = snap
|
|
248
|
+
? JSON.stringify([snap.panes, [...snap.tabs], [...snap.workspaces]])
|
|
249
|
+
: "";
|
|
250
|
+
if (key !== lastRaw && !disposed) {
|
|
251
|
+
lastRaw = key;
|
|
252
|
+
current = snap;
|
|
253
|
+
for (const cb of listeners) cb();
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// herdr vanished mid-call — try again next tick
|
|
257
|
+
} finally {
|
|
258
|
+
inFlight = false;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
void poll();
|
|
262
|
+
const timer = setInterval(poll, pollMs);
|
|
263
|
+
timer.unref?.();
|
|
264
|
+
return {
|
|
265
|
+
snapshot: () => current,
|
|
266
|
+
onChange: (cb) => listeners.push(cb),
|
|
267
|
+
dispose: () => {
|
|
268
|
+
disposed = true;
|
|
269
|
+
clearInterval(timer);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/store/index.ts
CHANGED
|
@@ -29,7 +29,16 @@ import {
|
|
|
29
29
|
createBoardWatcher,
|
|
30
30
|
type BoardWatcher,
|
|
31
31
|
} from "~/io/watcher";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
HARNESS,
|
|
34
|
+
createAgentsStore,
|
|
35
|
+
filterSessions,
|
|
36
|
+
type AgentSession,
|
|
37
|
+
type AgentsFilter,
|
|
38
|
+
type AgentsStore,
|
|
39
|
+
} from "./agents";
|
|
40
|
+
import { AGENT_ADAPTERS } from "./agent-adapters";
|
|
41
|
+
import { createHerdrSource } from "./herdr";
|
|
33
42
|
import {
|
|
34
43
|
createCalendarStore,
|
|
35
44
|
createGoogleEvent,
|
|
@@ -259,6 +268,10 @@ export interface UIState {
|
|
|
259
268
|
marked: Record<string, true>;
|
|
260
269
|
/** Active filter. */
|
|
261
270
|
filter: "all" | "today" | "overdue" | "tomorrow" | "followup";
|
|
271
|
+
/** Agents-zone harness filter (`f` while the Agents zone is active). */
|
|
272
|
+
agentsFilter: AgentsFilter;
|
|
273
|
+
/** Outcome of the last Enter (open session), shown in that session's detail. */
|
|
274
|
+
lastLaunch?: { sessionId: string; ok: boolean; text: string; ts: number };
|
|
262
275
|
/** Banner messages (errors, conflicts, undo notifications). */
|
|
263
276
|
banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
|
|
264
277
|
/** Open modal, if any. Keyboard handler routes input to the modal when set. */
|
|
@@ -349,6 +362,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
349
362
|
view: "kanban",
|
|
350
363
|
marked: {},
|
|
351
364
|
filter: "all",
|
|
365
|
+
agentsFilter: "all",
|
|
352
366
|
},
|
|
353
367
|
undo: [],
|
|
354
368
|
rev: 0,
|
|
@@ -362,12 +376,12 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
362
376
|
initialBoards.map((b) => b.board.filepath),
|
|
363
377
|
);
|
|
364
378
|
|
|
365
|
-
// Agents store has its own lifecycle (chokidar watcher on
|
|
366
|
-
// Shared dispose() boundary below so SIGINT cleans both. When
|
|
367
|
-
// zone is disabled we skip the watcher entirely (no
|
|
368
|
-
// and hand back an inert stub.
|
|
379
|
+
// Agents store has its own lifecycle (chokidar watcher on each agent CLI's
|
|
380
|
+
// session dirs). Shared dispose() boundary below so SIGINT cleans both. When
|
|
381
|
+
// the agents zone is disabled we skip the watcher entirely (no session reads
|
|
382
|
+
// at all) and hand back an inert stub.
|
|
369
383
|
const agentsStore: AgentsStore = enabledZones.agents
|
|
370
|
-
? createAgentsStore()
|
|
384
|
+
? createAgentsStore(AGENT_ADAPTERS, { herdr: createHerdrSource() })
|
|
371
385
|
: noopAgentsStore();
|
|
372
386
|
// Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
|
|
373
387
|
// (no network) when the agenda zone is disabled.
|
|
@@ -1230,6 +1244,28 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1230
1244
|
setState("ui", "zoomed", (z: boolean) => !z);
|
|
1231
1245
|
}
|
|
1232
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* Sessions the Agents zone lists, after the harness filter. The dashboard
|
|
1249
|
+
* strip additionally hides archived ones — they sort last, so row indices
|
|
1250
|
+
* into this list stay valid for both views.
|
|
1251
|
+
*/
|
|
1252
|
+
function agentSessions(): AgentSession[] {
|
|
1253
|
+
return filterSessions(agentsStore.sessions(), state.ui.agentsFilter);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function setLastLaunch(sessionId: string, ok: boolean, text: string): void {
|
|
1257
|
+
setState("ui", "lastLaunch", { sessionId, ok, text, ts: Date.now() });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Cycle the Agents-zone filter: all → cc → cx → oc → pi → all. */
|
|
1261
|
+
function cycleAgentsFilter(): AgentsFilter {
|
|
1262
|
+
const cycle: AgentsFilter[] = ["all", ...(Object.keys(HARNESS) as AgentsFilter[])];
|
|
1263
|
+
const next = cycle[(cycle.indexOf(state.ui.agentsFilter) + 1) % cycle.length]!;
|
|
1264
|
+
setState("ui", "agentsFilter", next);
|
|
1265
|
+
setState("ui", "row", 0);
|
|
1266
|
+
return next;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1233
1269
|
function setFilter(f: UIState["filter"]): void {
|
|
1234
1270
|
setState("ui", "filter", f);
|
|
1235
1271
|
// The cursor's row was an index into the unfiltered list — reset to top
|
|
@@ -1308,7 +1344,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1308
1344
|
|
|
1309
1345
|
/**
|
|
1310
1346
|
* Manual full refresh (the `r` key). Re-reads every board from disk, rescans
|
|
1311
|
-
*
|
|
1347
|
+
* agent sessions, and force-refetches the Agenda's calendar (bypassing
|
|
1312
1348
|
* the 30-min cache). Lets the user pull in external changes — a calendar
|
|
1313
1349
|
* event edited in the browser, a board touched elsewhere — without leaving
|
|
1314
1350
|
* tuiboard. Boards normally auto-reload via the file watcher; this also
|
|
@@ -1696,6 +1732,10 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1696
1732
|
config,
|
|
1697
1733
|
activeBoard,
|
|
1698
1734
|
agents: agentsStore,
|
|
1735
|
+
agentSessions,
|
|
1736
|
+
agentIndicators: config.statusIndicators,
|
|
1737
|
+
cycleAgentsFilter,
|
|
1738
|
+
setLastLaunch,
|
|
1699
1739
|
calendar: calendarStore,
|
|
1700
1740
|
// queries
|
|
1701
1741
|
getBoardByPath,
|
|
@@ -1781,7 +1821,7 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
|
|
|
1781
1821
|
/** Inert agents store for when the agents zone is disabled — no chokidar
|
|
1782
1822
|
* watcher, no `~/.claude` reads at all. */
|
|
1783
1823
|
function noopAgentsStore(): AgentsStore {
|
|
1784
|
-
return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
|
|
1824
|
+
return { sessions: () => [], herdr: () => undefined, refresh: () => {}, dispose: async () => {} };
|
|
1785
1825
|
}
|
|
1786
1826
|
|
|
1787
1827
|
/** Inert calendar store for when the agenda zone is disabled — no fetching. */
|
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -1,36 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* dashboard strip) and AgentsOnly (fullscreen list).
|
|
2
|
+
* Render of an AgentSession, in two densities:
|
|
4
3
|
*
|
|
5
|
-
*
|
|
4
|
+
* line (AgentsBar — the 5-row dashboard strip)
|
|
5
|
+
* cursor · status · harness · name · branch · model ……right-pinned: cwd_short · age
|
|
6
|
+
* Fitted to the measured row width (see agent-line.ts): on a narrow row the
|
|
7
|
+
* model goes first, then the branch, then the cwd — the name survives longest.
|
|
8
|
+
*
|
|
9
|
+
* card (AgentsOnly — fullscreen / zoomed Agents view)
|
|
10
|
+
* cursor · status · harness · name ………………………………… age
|
|
11
|
+
* model · branch · cwd (dimmed)
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import { Show, createMemo } from "solid-js";
|
|
14
|
+
import { Show, createMemo, createSignal, onMount } from "solid-js";
|
|
15
|
+
import { homedir } from "node:os";
|
|
9
16
|
|
|
17
|
+
import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
|
|
10
18
|
import { T } from "~/ui/glyphs";
|
|
11
|
-
import {
|
|
19
|
+
import { herdrPlace, type IndicatorStyle } from "~/store/herdr";
|
|
20
|
+
import {
|
|
21
|
+
HARNESS,
|
|
22
|
+
cwdShort,
|
|
23
|
+
formatAge,
|
|
24
|
+
shortModel,
|
|
25
|
+
type AgentProvider,
|
|
26
|
+
type AgentSession,
|
|
27
|
+
type AgentStatus,
|
|
28
|
+
} from "~/store/agents";
|
|
12
29
|
|
|
13
30
|
const STATUS_COLOR: Record<AgentStatus, string> = {
|
|
14
|
-
"live-
|
|
15
|
-
"live-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
31
|
+
"live-blocked": T.overdue, // needs you
|
|
32
|
+
"live-busy": T.today, // bright accent for actively-running
|
|
33
|
+
"live-done": T.done, // finished, not looked at yet
|
|
34
|
+
"live-idle": T.scheduled, // warm but quieter
|
|
35
|
+
"stale": T.bannerWarn,
|
|
36
|
+
"dormant": T.textDim,
|
|
37
|
+
"archived": T.textDone,
|
|
19
38
|
};
|
|
20
39
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
/**
|
|
41
|
+
* herdr's two `status_indicators` styles, copied as-is so the same state reads
|
|
42
|
+
* the same in both tools. `stale` is tuiboard's own (no herdr equivalent).
|
|
43
|
+
*/
|
|
44
|
+
const STATUS_GLYPH: Record<IndicatorStyle, Record<AgentStatus, string>> = {
|
|
45
|
+
dots: {
|
|
46
|
+
"live-blocked": "●",
|
|
47
|
+
"live-busy": "●",
|
|
48
|
+
"live-done": "●",
|
|
49
|
+
"live-idle": "○",
|
|
50
|
+
"stale": "△",
|
|
51
|
+
"dormant": "·",
|
|
52
|
+
"archived": "·",
|
|
53
|
+
},
|
|
54
|
+
symbols: {
|
|
55
|
+
"live-blocked": "×",
|
|
56
|
+
"live-busy": "◐",
|
|
57
|
+
"live-done": "✓",
|
|
58
|
+
"live-idle": "○",
|
|
59
|
+
"stale": "△",
|
|
60
|
+
"dormant": "·",
|
|
61
|
+
"archived": "·",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
/** One hue per harness so the badge reads before the letters do. */
|
|
68
|
+
export const HARNESS_COLOR: Record<AgentProvider, string> = {
|
|
69
|
+
"claude-code": T.warm, // orange
|
|
70
|
+
codex: T.accent, // blue-cyan
|
|
71
|
+
opencode: "#d27ee0", // violet-fuchsia
|
|
72
|
+
pi: "#c3d94e", // acid yellow-green
|
|
27
73
|
};
|
|
28
74
|
|
|
75
|
+
/** Card cwd: `~`-relative when under $HOME, shortened when still long. */
|
|
76
|
+
const HOME = homedir();
|
|
77
|
+
const CARD_CWD_MAX = 60;
|
|
78
|
+
function cardCwd(cwd: string): string {
|
|
79
|
+
const underHome = cwd === HOME || cwd.startsWith(HOME + "/") || cwd.startsWith(HOME + "\\");
|
|
80
|
+
const shown = underHome ? "~" + cwd.slice(HOME.length) : cwd;
|
|
81
|
+
return shown.length > CARD_CWD_MAX ? cwdShort(cwd) : shown;
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
interface AgentRowProps {
|
|
30
85
|
session: AgentSession;
|
|
31
86
|
cursor?: boolean;
|
|
32
87
|
/** Maximum chars for displayName before truncation. Default 40. */
|
|
33
88
|
nameMaxChars?: number;
|
|
89
|
+
/** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
|
|
90
|
+
variant?: "line" | "card";
|
|
91
|
+
/** Status glyph style (default symbols, as in herdr). */
|
|
92
|
+
indicators?: IndicatorStyle;
|
|
34
93
|
onClick?: () => void;
|
|
35
94
|
}
|
|
36
95
|
|
|
@@ -43,42 +102,121 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
43
102
|
const n = props.session.displayName;
|
|
44
103
|
return n.length > nameMax() ? n.slice(0, nameMax() - 1) + "…" : n;
|
|
45
104
|
});
|
|
105
|
+
const model = createMemo(() => shortModel(props.session.model));
|
|
106
|
+
|
|
107
|
+
// Measured row width; undefined until the first layout pass.
|
|
108
|
+
const [width, setWidth] = createSignal<number | undefined>();
|
|
109
|
+
let rowRef: { width: number } | undefined;
|
|
110
|
+
const measure = () => {
|
|
111
|
+
if (rowRef && rowRef.width > 0) setWidth(rowRef.width);
|
|
112
|
+
};
|
|
113
|
+
onMount(() => setTimeout(measure, 0));
|
|
114
|
+
const sizeProps = {
|
|
115
|
+
ref: (r: { width: number }) => (rowRef = r),
|
|
116
|
+
onSizeChange: measure,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const line = createMemo(() =>
|
|
120
|
+
layoutLine(
|
|
121
|
+
{
|
|
122
|
+
name: displayName(),
|
|
123
|
+
branch: props.session.gitBranch,
|
|
124
|
+
model: model(),
|
|
125
|
+
cwd: props.session.cwdShort,
|
|
126
|
+
age: ageStr().padStart(3),
|
|
127
|
+
},
|
|
128
|
+
width(),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const cardName = createMemo(() => layoutCardName(displayName(), width()));
|
|
132
|
+
const details = createMemo(() =>
|
|
133
|
+
layoutCardDetails(
|
|
134
|
+
[
|
|
135
|
+
model(),
|
|
136
|
+
props.session.herdr && herdrPlace(props.session.herdr),
|
|
137
|
+
props.session.gitBranch,
|
|
138
|
+
cardCwd(props.session.cwd),
|
|
139
|
+
],
|
|
140
|
+
width(),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// cursor · status glyph · harness badge — shared by both variants.
|
|
145
|
+
const lead = () => [
|
|
146
|
+
<span style={{ fg: props.cursor ? T.accent : T.textDim }}>
|
|
147
|
+
{props.cursor ? "▶ " : " "}
|
|
148
|
+
</span>,
|
|
149
|
+
<span style={{ fg: STATUS_COLOR[props.session.status] }}>
|
|
150
|
+
{STATUS_GLYPH[props.indicators ?? "symbols"][props.session.status]}{" "}
|
|
151
|
+
</span>,
|
|
152
|
+
<span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
|
|
153
|
+
{HARNESS[props.session.provider].code}{" "}
|
|
154
|
+
</span>,
|
|
155
|
+
];
|
|
46
156
|
|
|
47
157
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
158
|
+
<Show
|
|
159
|
+
when={props.variant === "card"}
|
|
160
|
+
fallback={
|
|
161
|
+
<box
|
|
162
|
+
{...sizeProps}
|
|
163
|
+
style={{
|
|
164
|
+
flexDirection: "row",
|
|
165
|
+
paddingLeft: 1,
|
|
166
|
+
paddingRight: 1,
|
|
167
|
+
backgroundColor: props.cursor ? T.cardBgCursor : undefined,
|
|
168
|
+
}}
|
|
169
|
+
onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
|
|
170
|
+
>
|
|
171
|
+
{/* `truncate` stays on as a safety net only — layoutLine already
|
|
172
|
+
fits the fields to the measured width with a tail cut, while
|
|
173
|
+
OpenTUI's own truncation would elide mid-string. */}
|
|
174
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
175
|
+
{lead()}
|
|
176
|
+
<span style={{ fg: T.text }}>{line().name}</span>
|
|
177
|
+
<Show when={line().branch}>
|
|
178
|
+
<span style={{ fg: T.warmDim }}>{" "}{line().branch}</span>
|
|
179
|
+
</Show>
|
|
180
|
+
<Show when={line().model}>
|
|
181
|
+
<span style={{ fg: T.textDim }}>{" "}{line().model}</span>
|
|
182
|
+
</Show>
|
|
183
|
+
</text>
|
|
184
|
+
{/* cwd + age pinned together on the right. The age is right-aligned in a
|
|
185
|
+
fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
|
|
186
|
+
cwd lands on the same column across rows — a 1- vs 2-digit age no
|
|
187
|
+
longer shoves the directory names out of vertical alignment. */}
|
|
188
|
+
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
189
|
+
<span style={{ fg: T.textDim }}>{" "}{line().right}</span>
|
|
190
|
+
</text>
|
|
191
|
+
</box>
|
|
192
|
+
}
|
|
56
193
|
>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
194
|
+
<box
|
|
195
|
+
{...sizeProps}
|
|
196
|
+
style={{
|
|
197
|
+
flexDirection: "column",
|
|
198
|
+
paddingLeft: 1,
|
|
199
|
+
paddingRight: 1,
|
|
200
|
+
backgroundColor: props.cursor ? T.cardBgCursor : undefined,
|
|
201
|
+
}}
|
|
202
|
+
onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
|
|
203
|
+
>
|
|
204
|
+
<box style={{ flexDirection: "row" }}>
|
|
205
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
206
|
+
{lead()}
|
|
207
|
+
<span style={{ fg: T.text }}>{cardName()}</span>
|
|
208
|
+
</text>
|
|
209
|
+
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
210
|
+
<span style={{ fg: T.textDim }}>{" "}{ageStr().padStart(3)}</span>
|
|
211
|
+
</text>
|
|
212
|
+
</box>
|
|
213
|
+
{/* Indent = width of "▶ ● cc " so the details line up under the name. */}
|
|
214
|
+
<box style={{ flexDirection: "row", paddingLeft: 7 }}>
|
|
215
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
216
|
+
<span style={{ fg: T.textDim }}>{details()}</span>
|
|
217
|
+
</text>
|
|
218
|
+
</box>
|
|
219
|
+
</box>
|
|
220
|
+
</Show>
|
|
83
221
|
);
|
|
84
222
|
}
|