tuiboard 0.12.0 → 0.13.1
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 +4 -0
- package/CHANGELOG.md +70 -0
- package/README.md +54 -1
- package/bin/tuiboard.ts +9 -0
- package/package.json +1 -1
- package/src/app-exit.ts +37 -0
- package/src/app.tsx +28 -13
- package/src/config/loader.ts +8 -0
- package/src/input/handleKey.ts +52 -3
- package/src/input/herdr-open.ts +132 -0
- package/src/input/open-session.ts +78 -29
- package/src/store/agent-adapters/claude-code.ts +1 -0
- package/src/store/agent-adapters/codex.ts +1 -0
- package/src/store/agent-adapters/opencode.ts +1 -0
- package/src/store/agent-adapters/pi.ts +1 -0
- package/src/store/agents.ts +48 -11
- package/src/store/herdr.ts +272 -0
- package/src/store/index.ts +4 -2
- package/src/ui/AgentRow.tsx +45 -13
- package/src/ui/AgentsBar.tsx +11 -7
- package/src/ui/Modal.tsx +24 -1
- package/src/ui/agent-cursor.ts +49 -0
- package/src/views/AgentsOnly.tsx +21 -14
|
@@ -179,6 +179,8 @@ export interface LaunchStep {
|
|
|
179
179
|
* replaced with it.
|
|
180
180
|
*/
|
|
181
181
|
captureId?: (stdout: string) => string | undefined;
|
|
182
|
+
/** Best-effort rollback (with `{id}`) if a later step fails, e.g. close the tab this step opened. */
|
|
183
|
+
undo?: LaunchStep;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
export interface LaunchTarget {
|
|
@@ -365,39 +367,86 @@ export function describePlan(steps: LaunchStep[]): string {
|
|
|
365
367
|
|
|
366
368
|
/**
|
|
367
369
|
* Run a plan. Throws with a readable message on the first failing step.
|
|
368
|
-
* Short IPC steps
|
|
369
|
-
* GUI launches are detached so tuiboard doesn't wait on the
|
|
370
|
+
* Short IPC steps are awaited (their output feeds the next step) without
|
|
371
|
+
* blocking the UI; GUI launches are detached so tuiboard doesn't wait on the
|
|
372
|
+
* new window.
|
|
370
373
|
*/
|
|
371
374
|
export async function runLaunchPlan(steps: LaunchStep[]): Promise<void> {
|
|
372
|
-
const
|
|
375
|
+
const undos: LaunchStep[] = [];
|
|
373
376
|
let id: string | undefined;
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
await new Promise<void>((resolve, reject) => {
|
|
379
|
-
const child = spawn(exe, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
380
|
-
child.once("error", (e) => reject(new Error(`${step.cmd}: ${e.message}`)));
|
|
381
|
-
child.once("spawn", () => {
|
|
382
|
-
child.unref();
|
|
383
|
-
resolve();
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
const res = spawnSync(exe, args, {
|
|
389
|
-
input: step.input,
|
|
390
|
-
encoding: "utf8",
|
|
391
|
-
windowsHide: true,
|
|
392
|
-
timeout: step.timeoutMs ?? 10_000,
|
|
393
|
-
});
|
|
394
|
-
if (res.error) throw new Error(`${step.cmd}: ${res.error.message}`);
|
|
395
|
-
if (res.status !== 0) {
|
|
396
|
-
throw new Error(`${step.cmd}: ${(res.stderr || "").trim() || `exit ${res.status}`}`);
|
|
377
|
+
try {
|
|
378
|
+
for (const step of steps) {
|
|
379
|
+
id = await runStep(step, id);
|
|
380
|
+
if (step.undo) undos.unshift(step.undo);
|
|
397
381
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
382
|
+
} catch (e) {
|
|
383
|
+
for (const undo of undos) {
|
|
384
|
+
await runStep(undo, id).catch(() => undefined);
|
|
401
385
|
}
|
|
386
|
+
throw e;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** CLI error text, unwrapping JSON errors like herdr's `{"error":{"message":…}}`. */
|
|
391
|
+
function errorText(out: string): string {
|
|
392
|
+
const text = out.trim();
|
|
393
|
+
try {
|
|
394
|
+
const msg = JSON.parse(text)?.error?.message;
|
|
395
|
+
if (typeof msg === "string") return msg;
|
|
396
|
+
} catch {
|
|
397
|
+
// not JSON
|
|
398
|
+
}
|
|
399
|
+
return text;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function runStep(step: LaunchStep, id: string | undefined): Promise<string | undefined> {
|
|
403
|
+
const { spawn } = await import("node:child_process");
|
|
404
|
+
const args = step.args.map((a) => (id !== undefined ? a.replaceAll("{id}", id) : a));
|
|
405
|
+
const exe = Bun.which(step.cmd) ?? step.cmd;
|
|
406
|
+
if (step.detached) {
|
|
407
|
+
await new Promise<void>((resolve, reject) => {
|
|
408
|
+
const child = spawn(exe, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
409
|
+
child.once("error", (e) => reject(new Error(`${step.cmd}: ${e.message}`)));
|
|
410
|
+
child.once("spawn", () => {
|
|
411
|
+
child.unref();
|
|
412
|
+
resolve();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
return id;
|
|
416
|
+
}
|
|
417
|
+
const { status, stdout, stderr } = await new Promise<{
|
|
418
|
+
status: number | null;
|
|
419
|
+
stdout: string;
|
|
420
|
+
stderr: string;
|
|
421
|
+
}>((resolve, reject) => {
|
|
422
|
+
const child = spawn(exe, args, { windowsHide: true });
|
|
423
|
+
let out = "";
|
|
424
|
+
let err = "";
|
|
425
|
+
child.stdout?.on("data", (d) => (out += d));
|
|
426
|
+
child.stderr?.on("data", (d) => (err += d));
|
|
427
|
+
const timeoutMs = step.timeoutMs ?? 10_000;
|
|
428
|
+
const timer = setTimeout(() => {
|
|
429
|
+
child.kill();
|
|
430
|
+
reject(new Error(`${step.cmd}: timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
431
|
+
}, timeoutMs);
|
|
432
|
+
child.once("error", (e) => {
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
reject(new Error(`${step.cmd}: ${e.message}`));
|
|
435
|
+
});
|
|
436
|
+
child.once("close", (code) => {
|
|
437
|
+
clearTimeout(timer);
|
|
438
|
+
resolve({ status: code, stdout: out, stderr: err });
|
|
439
|
+
});
|
|
440
|
+
if (step.input !== undefined) child.stdin?.end(step.input);
|
|
441
|
+
else child.stdin?.end();
|
|
442
|
+
});
|
|
443
|
+
if (status !== 0) {
|
|
444
|
+
throw new Error(`${step.cmd}: ${errorText(stderr) || errorText(stdout) || `exit ${status}`}`);
|
|
445
|
+
}
|
|
446
|
+
if (step.captureId) {
|
|
447
|
+
const captured = step.captureId(stdout);
|
|
448
|
+
if (!captured) throw new Error(`${step.cmd}: unexpected output`);
|
|
449
|
+
return captured;
|
|
402
450
|
}
|
|
451
|
+
return id;
|
|
403
452
|
}
|
|
@@ -389,6 +389,7 @@ export function createCodexAdapter(codexHome = defaultCodexHome()): AgentAdapter
|
|
|
389
389
|
gitBranch: parsed.gitBranch ?? row?.git_branch ?? undefined,
|
|
390
390
|
model: parsed.model ?? row?.model ?? undefined,
|
|
391
391
|
resumeCommand: `codex resume ${f.threadId}`,
|
|
392
|
+
resumeArgv: ["codex", "resume", f.threadId],
|
|
392
393
|
});
|
|
393
394
|
}
|
|
394
395
|
// Drop cache entries for files that disappeared (archived / deleted).
|
|
@@ -178,6 +178,7 @@ export function readOpenCodeSessions(dbPath: string, now: number): AgentSession[
|
|
|
178
178
|
// Assistant messages carry `modelID`; user messages `model.modelID`.
|
|
179
179
|
model: lastData?.modelID ?? lastData?.model?.modelID,
|
|
180
180
|
resumeCommand: `opencode --session ${s.id}`,
|
|
181
|
+
resumeArgv: ["opencode", "--session", s.id],
|
|
181
182
|
} satisfies AgentSession;
|
|
182
183
|
});
|
|
183
184
|
} finally {
|
|
@@ -238,6 +238,7 @@ export function createPiAdapter(sessionsDir = defaultPiSessionsDir()): AgentAdap
|
|
|
238
238
|
lastAssistant: p.lastAssistant,
|
|
239
239
|
model: p.model,
|
|
240
240
|
resumeCommand: `pi --session ${sessionId}`,
|
|
241
|
+
resumeArgv: ["pi", "--session", sessionId],
|
|
241
242
|
};
|
|
242
243
|
});
|
|
243
244
|
const live = new Set(files.map((f) => f.path));
|
package/src/store/agents.ts
CHANGED
|
@@ -13,6 +13,13 @@ import { resolve, sep } from "node:path";
|
|
|
13
13
|
import chokidar from "chokidar";
|
|
14
14
|
import { createSignal } from "solid-js";
|
|
15
15
|
|
|
16
|
+
import {
|
|
17
|
+
linkHerdrSessions,
|
|
18
|
+
type HerdrLink,
|
|
19
|
+
type HerdrSnapshot,
|
|
20
|
+
type HerdrSource,
|
|
21
|
+
} from "~/store/herdr";
|
|
22
|
+
|
|
16
23
|
/** Threshold: session untouched longer than this is "archived" (won't show in compact list). */
|
|
17
24
|
export const DORMANT_AFTER_MS = 7 * 86_400 * 1000;
|
|
18
25
|
|
|
@@ -30,7 +37,11 @@ export const HARNESS: Record<AgentProvider, { code: string; name: string }> = {
|
|
|
30
37
|
export type AgentsFilter = "all" | AgentProvider;
|
|
31
38
|
|
|
32
39
|
export type AgentStatus =
|
|
40
|
+
/** Waiting for you (permission/question) — known only via herdr. */
|
|
41
|
+
| "live-blocked"
|
|
33
42
|
| "live-busy"
|
|
43
|
+
/** Finished its turn, not looked at yet — known only via herdr. */
|
|
44
|
+
| "live-done"
|
|
34
45
|
| "live-idle"
|
|
35
46
|
/** Looks busy, but stopped updating — the process likely crashed. */
|
|
36
47
|
| "stale"
|
|
@@ -58,6 +69,10 @@ export interface AgentSession {
|
|
|
58
69
|
model?: string;
|
|
59
70
|
/** Shell command that resumes this session when run from `cwd`. */
|
|
60
71
|
resumeCommand: string;
|
|
72
|
+
/** Same, as argv (`[binary, ...args]`) — for launchers that start the agent directly. */
|
|
73
|
+
resumeArgv: string[];
|
|
74
|
+
/** Set when the session is open in a herdr pane. */
|
|
75
|
+
herdr?: HerdrLink;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** One agent CLI's session source. */
|
|
@@ -109,18 +124,32 @@ export function formatAge(ts: number, now: number): string {
|
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
const STATUS_RANK: Record<AgentStatus, number> = {
|
|
112
|
-
"live-
|
|
113
|
-
"live-
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
127
|
+
"live-blocked": 0,
|
|
128
|
+
"live-busy": 1,
|
|
129
|
+
"live-done": 2,
|
|
130
|
+
"live-idle": 3,
|
|
131
|
+
"stale": 4,
|
|
132
|
+
"dormant": 5,
|
|
133
|
+
"archived": 6,
|
|
117
134
|
};
|
|
118
135
|
|
|
136
|
+
/** Is the agent process running (as far as we can tell)? */
|
|
137
|
+
export function isLive(status: AgentStatus): boolean {
|
|
138
|
+
return status.startsWith("live-");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Most recently active first; status only breaks ties. Archived sessions
|
|
143
|
+
* always go last — the dashboard strip hides them and relies on that to keep
|
|
144
|
+
* cursor indices aligned with the full list.
|
|
145
|
+
*/
|
|
119
146
|
export function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
120
147
|
return arr.slice().sort((a, b) => {
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
148
|
+
const archived = Number(a.status === "archived") - Number(b.status === "archived");
|
|
149
|
+
if (archived !== 0) return archived;
|
|
150
|
+
const recency = b.lastActivityMs - a.lastActivityMs;
|
|
151
|
+
if (recency !== 0) return recency;
|
|
152
|
+
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
124
153
|
});
|
|
125
154
|
}
|
|
126
155
|
|
|
@@ -128,6 +157,8 @@ export function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
|
128
157
|
|
|
129
158
|
export interface AgentsStore {
|
|
130
159
|
sessions: () => AgentSession[];
|
|
160
|
+
/** Latest herdr snapshot (undefined without herdr). */
|
|
161
|
+
herdr: () => HerdrSnapshot | undefined;
|
|
131
162
|
refresh: () => void;
|
|
132
163
|
dispose: () => Promise<void>;
|
|
133
164
|
}
|
|
@@ -148,7 +179,10 @@ const MISSING_PATH_POLL_MS = 5000;
|
|
|
148
179
|
*/
|
|
149
180
|
export function createAgentsStore(
|
|
150
181
|
adapters: AgentAdapter[],
|
|
151
|
-
{
|
|
182
|
+
{
|
|
183
|
+
missingPathPollMs = MISSING_PATH_POLL_MS,
|
|
184
|
+
herdr,
|
|
185
|
+
}: { missingPathPollMs?: number; herdr?: HerdrSource } = {},
|
|
152
186
|
): AgentsStore {
|
|
153
187
|
const [sessions, setSessions] = createSignal<AgentSession[]>([]);
|
|
154
188
|
const byAdapter = new Map<AgentAdapter, AgentSession[]>();
|
|
@@ -162,8 +196,10 @@ export function createAgentsStore(
|
|
|
162
196
|
}
|
|
163
197
|
|
|
164
198
|
function publish(): void {
|
|
165
|
-
|
|
199
|
+
const all = [...byAdapter.values()].flat();
|
|
200
|
+
setSessions(sortSessions(linkHerdrSessions(all, herdr?.snapshot())));
|
|
166
201
|
}
|
|
202
|
+
herdr?.onChange(publish);
|
|
167
203
|
|
|
168
204
|
function refresh(): void {
|
|
169
205
|
const now = Date.now();
|
|
@@ -231,8 +267,9 @@ export function createAgentsStore(
|
|
|
231
267
|
async function dispose() {
|
|
232
268
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
233
269
|
clearInterval(missingPoll);
|
|
270
|
+
herdr?.dispose();
|
|
234
271
|
await watcher.close();
|
|
235
272
|
}
|
|
236
273
|
|
|
237
|
-
return { sessions, refresh, dispose };
|
|
274
|
+
return { sessions, herdr: () => herdr?.snapshot(), refresh, dispose };
|
|
238
275
|
}
|
|
@@ -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
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type AgentsStore,
|
|
39
39
|
} from "./agents";
|
|
40
40
|
import { AGENT_ADAPTERS } from "./agent-adapters";
|
|
41
|
+
import { createHerdrSource } from "./herdr";
|
|
41
42
|
import {
|
|
42
43
|
createCalendarStore,
|
|
43
44
|
createGoogleEvent,
|
|
@@ -380,7 +381,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
380
381
|
// the agents zone is disabled we skip the watcher entirely (no session reads
|
|
381
382
|
// at all) and hand back an inert stub.
|
|
382
383
|
const agentsStore: AgentsStore = enabledZones.agents
|
|
383
|
-
? createAgentsStore(AGENT_ADAPTERS)
|
|
384
|
+
? createAgentsStore(AGENT_ADAPTERS, { herdr: createHerdrSource() })
|
|
384
385
|
: noopAgentsStore();
|
|
385
386
|
// Calendar feeds (read-only) merged into the Agenda zone. Skipped entirely
|
|
386
387
|
// (no network) when the agenda zone is disabled.
|
|
@@ -1732,6 +1733,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1732
1733
|
activeBoard,
|
|
1733
1734
|
agents: agentsStore,
|
|
1734
1735
|
agentSessions,
|
|
1736
|
+
agentIndicators: config.statusIndicators,
|
|
1735
1737
|
cycleAgentsFilter,
|
|
1736
1738
|
setLastLaunch,
|
|
1737
1739
|
calendar: calendarStore,
|
|
@@ -1819,7 +1821,7 @@ export type TuiStore = ReturnType<typeof createTuiStore>;
|
|
|
1819
1821
|
/** Inert agents store for when the agents zone is disabled — no chokidar
|
|
1820
1822
|
* watcher, no `~/.claude` reads at all. */
|
|
1821
1823
|
function noopAgentsStore(): AgentsStore {
|
|
1822
|
-
return { sessions: () => [], refresh: () => {}, dispose: async () => {} };
|
|
1824
|
+
return { sessions: () => [], herdr: () => undefined, refresh: () => {}, dispose: async () => {} };
|
|
1823
1825
|
}
|
|
1824
1826
|
|
|
1825
1827
|
/** Inert calendar store for when the agenda zone is disabled — no fetching. */
|
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { homedir } from "node:os";
|
|
|
16
16
|
|
|
17
17
|
import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
|
|
18
18
|
import { T } from "~/ui/glyphs";
|
|
19
|
+
import { herdrPlace, type IndicatorStyle } from "~/store/herdr";
|
|
19
20
|
import {
|
|
20
21
|
HARNESS,
|
|
21
22
|
cwdShort,
|
|
@@ -27,21 +28,42 @@ import {
|
|
|
27
28
|
} from "~/store/agents";
|
|
28
29
|
|
|
29
30
|
const STATUS_COLOR: Record<AgentStatus, string> = {
|
|
30
|
-
"live-
|
|
31
|
-
"live-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
},
|
|
43
63
|
};
|
|
44
64
|
|
|
65
|
+
|
|
66
|
+
|
|
45
67
|
/** One hue per harness so the badge reads before the letters do. */
|
|
46
68
|
export const HARNESS_COLOR: Record<AgentProvider, string> = {
|
|
47
69
|
"claude-code": T.warm, // orange
|
|
@@ -66,6 +88,8 @@ interface AgentRowProps {
|
|
|
66
88
|
nameMaxChars?: number;
|
|
67
89
|
/** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
|
|
68
90
|
variant?: "line" | "card";
|
|
91
|
+
/** Status glyph style (default symbols, as in herdr). */
|
|
92
|
+
indicators?: IndicatorStyle;
|
|
69
93
|
onClick?: () => void;
|
|
70
94
|
}
|
|
71
95
|
|
|
@@ -106,7 +130,15 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
106
130
|
);
|
|
107
131
|
const cardName = createMemo(() => layoutCardName(displayName(), width()));
|
|
108
132
|
const details = createMemo(() =>
|
|
109
|
-
layoutCardDetails(
|
|
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
|
+
),
|
|
110
142
|
);
|
|
111
143
|
|
|
112
144
|
// cursor · status glyph · harness badge — shared by both variants.
|
|
@@ -115,7 +147,7 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
115
147
|
{props.cursor ? "▶ " : " "}
|
|
116
148
|
</span>,
|
|
117
149
|
<span style={{ fg: STATUS_COLOR[props.session.status] }}>
|
|
118
|
-
{STATUS_GLYPH[props.session.status]}{" "}
|
|
150
|
+
{STATUS_GLYPH[props.indicators ?? "symbols"][props.session.status]}{" "}
|
|
119
151
|
</span>,
|
|
120
152
|
<span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
|
|
121
153
|
{HARNESS[props.session.provider].code}{" "}
|