tuiboard 0.12.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 +4 -0
- package/CHANGELOG.md +56 -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 +23 -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 +1 -0
- package/src/ui/Modal.tsx +24 -1
- package/src/views/AgentsOnly.tsx +1 -0
|
@@ -59,6 +59,10 @@ archive_column: Archive
|
|
|
59
59
|
# PowerShell (pwsh, else powershell); elsewhere your $SHELL. Force one with:
|
|
60
60
|
# resume_shell: auto # bash | zsh | fish | nu | pwsh | powershell | cmd
|
|
61
61
|
#
|
|
62
|
+
# Agent status symbols, the same as herdr's: × waiting for you, ◐ working,
|
|
63
|
+
# ✓ done, ○ idle, · closed (△ = turn stopped updating). Or colored dots:
|
|
64
|
+
# status_indicators: symbols # symbols (default) | dots
|
|
65
|
+
#
|
|
62
66
|
# Enter not doing what you expect? From the directory you run tuiboard in (in a
|
|
63
67
|
# checkout of the repo): `bun run agents:open <session-id-prefix> --dry-run`
|
|
64
68
|
# prints the detected terminal, shell and exact command; the `o` detail of a
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
|
|
13
|
+
herdr's live state for every session open there, speaks herdr's status
|
|
14
|
+
symbols, and `H` jumps to a session in herdr or resumes it in the right
|
|
15
|
+
workspace. Plus fixes found using it all day on Windows.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Live agent state from herdr** (#37). When herdr is running, tuiboard polls
|
|
19
|
+
`herdr api snapshot` and links every pane running Claude Code, Codex,
|
|
20
|
+
OpenCode or Pi to its session — by the session id/file herdr reports (with
|
|
21
|
+
herdr's agent integrations installed), else by agent and directory. Those
|
|
22
|
+
sessions take herdr's state, including two new ones: **waiting for you** and
|
|
23
|
+
**done**; idle Codex/OpenCode/Pi sessions become visible, and long Claude
|
|
24
|
+
turns no longer show as stale (#22). Zoomed cards and the `o` detail show the
|
|
25
|
+
herdr workspace and tab.
|
|
26
|
+
- **herdr's status symbols** — `×` waiting, `◐` working, `✓` done, `○` idle,
|
|
27
|
+
`·` closed (tuiboard's own `△` for stale) — are the default for everyone, so
|
|
28
|
+
both tools read the same; `status_indicators: dots` keeps colored dots.
|
|
29
|
+
- **`H` opens sessions in herdr** (#38): focuses the pane a session is open
|
|
30
|
+
in, or resumes it in herdr — a new tab named after the session, in the
|
|
31
|
+
workspace that already holds that directory (else one named like the
|
|
32
|
+
folder, else the focused one). **Enter** on a session already open in herdr
|
|
33
|
+
focuses it instead of starting a second copy.
|
|
34
|
+
- **Omarchy bar widget in the repo** (#27): `omarchy-plugin/` — an
|
|
35
|
+
overdue/today badge and a Today/Tomorrow panel (done, undone, defer, open
|
|
36
|
+
tuiboard) built on `tuiboard summary` / `tuiboard task`, installed with
|
|
37
|
+
`omarchy-plugin/install.sh`. Repo only, not part of the npm package.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- **Agent sessions are sorted by most recent activity** (#43), newest first,
|
|
41
|
+
instead of by status first — a session used 40 seconds ago no longer sits
|
|
42
|
+
below ones idle in herdr for hours. Status only breaks ties; archived
|
|
43
|
+
sessions stay at the bottom.
|
|
44
|
+
- Launch steps for Enter and `H` run asynchronously: a slow start (cold
|
|
45
|
+
PowerShell, an agent booting in herdr) no longer freezes the UI.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- **Quitting gives the terminal back** (#47). `q`, Ctrl+C and termination
|
|
49
|
+
signals exited without tearing down the renderer, leaving mouse tracking
|
|
50
|
+
and the alternate screen on — moving the mouse at the shell then printed
|
|
51
|
+
`51;7;45M…`. tuiboard now restores the terminal before exiting, and the
|
|
52
|
+
`tuiboard` launcher resets mouse/paste/focus reporting and the cursor after
|
|
53
|
+
the app ends, whatever way it ended.
|
|
54
|
+
- **`H` on Windows with Codex, OpenCode and Pi** (#41). npm-installed CLIs on
|
|
55
|
+
Windows are an extensionless sh shim next to `.cmd`/`.ps1`, which herdr
|
|
56
|
+
can't launch directly ("not a valid Win32 application"); on Windows the
|
|
57
|
+
resume command is typed into the new pane's shell instead.
|
|
58
|
+
- **Responsive layout follows the renderer's terminal size** (#45), the size
|
|
59
|
+
the frame is drawn at, instead of `process.stdout.columns`.
|
|
60
|
+
|
|
61
|
+
### Known issues
|
|
62
|
+
- On Windows, herdr may not detect Pi sessions as agents (#49): they open
|
|
63
|
+
fine, but without herdr's live state.
|
|
64
|
+
|
|
10
65
|
## [0.12.0] - 2026-09-17
|
|
11
66
|
|
|
12
67
|
The Agents zone stops being Claude-Code-only: it now lists **Codex, OpenCode
|
|
@@ -419,6 +474,7 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
419
474
|
|
|
420
475
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
421
476
|
|
|
477
|
+
[0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
|
|
422
478
|
[0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
|
|
423
479
|
[0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
|
|
424
480
|
[0.10.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.10.0
|
package/README.md
CHANGED
|
@@ -85,6 +85,20 @@ record of running processes, so their sessions show as busy while a turn is in
|
|
|
85
85
|
progress (stale if a turn stops updating for 30 minutes), never as
|
|
86
86
|
idle-but-open.
|
|
87
87
|
|
|
88
|
+
Each session's state uses the same symbols as [herdr](https://herdr.dev):
|
|
89
|
+
`×` waiting for you, `◐` working, `✓` done, `○` idle, `·` closed, plus
|
|
90
|
+
tuiboard's own `△` for a turn that stopped updating (`status_indicators: dots`
|
|
91
|
+
switches to colored dots). **With herdr running**, tuiboard reads its live
|
|
92
|
+
state for every session open in a herdr pane — so "waiting for you", "done"
|
|
93
|
+
and "idle" show up for every agent, and a long Claude turn no longer turns
|
|
94
|
+
stale — and the zoomed cards say where each one lives (`herdr blits · tab 3`).
|
|
95
|
+
herdr reports the exact session when its agent integration is installed
|
|
96
|
+
(`herdr integration install claude|codex|opencode|pi`); otherwise tuiboard
|
|
97
|
+
matches by agent and directory. `H` jumps to a session in herdr — or resumes
|
|
98
|
+
it there, in a new tab of the workspace that holds its project — and Enter on
|
|
99
|
+
a session that's already open in herdr takes you to it instead of starting a
|
|
100
|
+
second copy.
|
|
101
|
+
|
|
88
102
|
Every session carries a colored two-letter harness badge — `cc` Claude Code,
|
|
89
103
|
`cx` Codex, `oc` OpenCode, `pi` Pi — plus the model it ran on. The dashboard strip keeps
|
|
90
104
|
one line per session (on a narrow row the model gives way first, then the
|
|
@@ -448,9 +462,10 @@ session (until the next terminal resize).
|
|
|
448
462
|
| Key | Action |
|
|
449
463
|
|---|---|
|
|
450
464
|
| `j` / `k` | Move the cursor down / up the session list |
|
|
451
|
-
| `Enter` |
|
|
465
|
+
| `Enter` | Go to the session if it's open in herdr; otherwise open (resume) it in a new tab/window of your terminal — tmux, herdr, WezTerm, Windows Terminal, Ghostty, or the OS default; falls back to copying the command (`resume_terminal` to force one) |
|
|
452
466
|
| `c` | Copy a one-paste `cd … && <resume>` command (e.g. `claude --resume <id>`) for the selected session — drop it into any tab/pane to land in the right dir and resume (no WezTerm needed; format is `copy_resume_command`) |
|
|
453
467
|
| `o` | Session detail (harness, model, cwd, branch, last prompts, resume command, result of the last `Enter`) |
|
|
468
|
+
| `H` | herdr: go to the session's pane, or resume it in a new tab of its project's workspace (herdr must be running) |
|
|
454
469
|
| `f` | Filter by harness: all → `cc` Claude Code → `cx` Codex → `oc` OpenCode → `pi` Pi (shown in the panel title; outside the Agents zone `f` is the board filter) |
|
|
455
470
|
|
|
456
471
|
### Task actions (work in board, planner, AND timeline zones)
|
|
@@ -554,10 +569,48 @@ An ambiguous match is refused rather than guessed at.
|
|
|
554
569
|
Exit 3 is the mtime watermark: a write is refused rather than allowed to
|
|
555
570
|
clobber an edit made in the TUI or another editor in the meantime.
|
|
556
571
|
|
|
572
|
+
## Omarchy bar widget
|
|
573
|
+
|
|
574
|
+
On [Omarchy](https://omarchy.org), `omarchy-plugin/` ships a bar widget built
|
|
575
|
+
on `tuiboard summary` and `tuiboard task`: an overdue/today badge in the bar,
|
|
576
|
+
and a panel with the Today/Tomorrow planner where a task can be marked
|
|
577
|
+
done/undone or deferred to tomorrow without leaving the bar. Right-click the
|
|
578
|
+
badge to force a refresh, middle-click to open tuiboard itself.
|
|
579
|
+
|
|
580
|
+
**Requires** `tuiboard` on `PATH` (see [Install](#install) above) and Omarchy
|
|
581
|
+
with its shell plugin support.
|
|
582
|
+
|
|
583
|
+
**Install** (from a checkout of this repo):
|
|
584
|
+
|
|
585
|
+
```bash
|
|
586
|
+
git clone https://github.com/NazzarenoGiannelli/tuiboard.git
|
|
587
|
+
cd tuiboard
|
|
588
|
+
./omarchy-plugin/install.sh
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
This symlinks `omarchy-plugin/` into `~/.config/omarchy/plugins/nazz.tuiboard`
|
|
592
|
+
and enables it in the bar's right section. `omarchy plugin add <git-url>`
|
|
593
|
+
isn't used here — it clones a git repo and expects `manifest.json` at its
|
|
594
|
+
root, which doesn't fit a widget living inside this monorepo, and there's no
|
|
595
|
+
separate Omarchy plugin marketplace to publish to at the time of writing. The
|
|
596
|
+
symlink means `omarchy plugin update` doesn't apply; update by pulling this
|
|
597
|
+
repo instead (`git pull`, then `omarchy-shell shell rescanPlugins` if the bar
|
|
598
|
+
doesn't pick it up on its own).
|
|
599
|
+
|
|
600
|
+
Refresh interval, the `tuiboard summary`/`tuiboard task` commands, the open
|
|
601
|
+
command, and the completion sound are all configurable from Omarchy's own
|
|
602
|
+
plugin settings (`manifest.json`'s schema) — no config file to hand-edit.
|
|
603
|
+
|
|
604
|
+
Uninstall: `omarchy plugin remove nazz.tuiboard`.
|
|
605
|
+
|
|
557
606
|
## Status
|
|
558
607
|
|
|
559
608
|
See [CHANGELOG.md](CHANGELOG.md) for the full release history.
|
|
560
609
|
|
|
610
|
+
- **v0.13** — tuiboard + herdr: live agent state from herdr (waiting for you,
|
|
611
|
+
working, done, idle) with herdr's status symbols, `H` to jump to a session
|
|
612
|
+
in herdr or resume it in its project's workspace, sessions sorted by most
|
|
613
|
+
recent activity, and the terminal properly restored on quit.
|
|
561
614
|
- **v0.12** — the Agents zone goes multi-agent: Codex, OpenCode and Pi sessions
|
|
562
615
|
next to Claude Code, with a colored harness badge, the model, a harness
|
|
563
616
|
filter (`f`) and two-line cards when zoomed. Enter reopens a session in the
|
package/bin/tuiboard.ts
CHANGED
|
@@ -85,8 +85,17 @@ const child = spawn(
|
|
|
85
85
|
env: { ...process.env, TUIBOARD_SPLASH_DONE: "1", TUIBOARD_READY_FLAG: readyFlag },
|
|
86
86
|
},
|
|
87
87
|
);
|
|
88
|
+
// Safety net: whatever way the app ended (crash, kill), give the shell a sane
|
|
89
|
+
// terminal back — mouse reporting off (else moving the mouse prints
|
|
90
|
+
// `51;7;45M…`), focus/paste reporting off, cursor visible. Harmless when the
|
|
91
|
+
// app already restored everything.
|
|
92
|
+
const RESET_TERMINAL =
|
|
93
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse
|
|
94
|
+
"\x1b[?1004l\x1b[?2004l" + // focus events, bracketed paste
|
|
95
|
+
"\x1b[?25h"; // cursor
|
|
88
96
|
child.on("exit", (code, signal) => {
|
|
89
97
|
stopSplash();
|
|
98
|
+
try { process.stdout.write(RESET_TERMINAL); } catch { /* ignore */ }
|
|
90
99
|
process.exit(code ?? (signal ? 1 : 0));
|
|
91
100
|
});
|
|
92
101
|
child.on("error", (err) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/app-exit.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way out of the TUI. Destroying the OpenTUI renderer is what gives
|
|
3
|
+
* the terminal back — mouse tracking off, main screen, cooked input, cursor —
|
|
4
|
+
* and a bare `process.exit` skips it (OpenTUI only cleans up on `beforeExit`
|
|
5
|
+
* and its own signal/Ctrl+C handling), which left shells printing mouse
|
|
6
|
+
* reports like `51;7;45M` after quitting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CliRenderer } from "@opentui/core";
|
|
10
|
+
|
|
11
|
+
let renderer: CliRenderer | undefined;
|
|
12
|
+
let quitting = false;
|
|
13
|
+
|
|
14
|
+
export function registerRenderer(r: CliRenderer): void {
|
|
15
|
+
renderer = r;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Restore the terminal, run `cleanup`, exit. Safe to call more than once. */
|
|
19
|
+
export async function quitApp(cleanup: () => Promise<void>, code = 0): Promise<never> {
|
|
20
|
+
if (!quitting) {
|
|
21
|
+
quitting = true;
|
|
22
|
+
try {
|
|
23
|
+
renderer?.destroy();
|
|
24
|
+
// A destroy requested mid-frame completes when that frame ends.
|
|
25
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
26
|
+
} catch {
|
|
27
|
+
// Never let a teardown error keep the process alive.
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await cleanup();
|
|
31
|
+
} catch {
|
|
32
|
+
// Same.
|
|
33
|
+
}
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
return new Promise<never>(() => {});
|
|
37
|
+
}
|
package/src/app.tsx
CHANGED
|
@@ -21,8 +21,11 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
|
|
24
|
-
import { createMemo } from "solid-js";
|
|
25
|
-
import {
|
|
24
|
+
import { createEffect, createMemo } from "solid-js";
|
|
25
|
+
import { createCliRenderer } from "@opentui/core";
|
|
26
|
+
import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
|
27
|
+
|
|
28
|
+
import { quitApp, registerRenderer } from "~/app-exit";
|
|
26
29
|
|
|
27
30
|
import { parseArgs, type ViewKind } from "~/cli/args";
|
|
28
31
|
import { loadConfig } from "~/config/loader";
|
|
@@ -84,12 +87,8 @@ if (needsOnboarding) {
|
|
|
84
87
|
store.openBoardNew(true);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
process.on("SIGINT", () =>
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
process.on("SIGTERM", () => {
|
|
91
|
-
store.dispose().finally(() => process.exit(0));
|
|
92
|
-
});
|
|
90
|
+
process.on("SIGINT", () => void quitApp(() => store.dispose()));
|
|
91
|
+
process.on("SIGTERM", () => void quitApp(() => store.dispose()));
|
|
93
92
|
|
|
94
93
|
// ─── Responsive layout ──────────────────────────────────────────────────────
|
|
95
94
|
// Auto-hide optional zones when the terminal isn't wide enough to host them
|
|
@@ -102,8 +101,7 @@ process.on("SIGTERM", () => {
|
|
|
102
101
|
// This only reports what FITS. The store combines it with each zone's enabled
|
|
103
102
|
// flag and the user's desired visibility, so F1/F2/F3 toggles persist across
|
|
104
103
|
// resizes and a disabled/hidden zone is never force-shown.
|
|
105
|
-
function applyResponsiveLayout(): void {
|
|
106
|
-
const width = process.stdout.columns ?? 200;
|
|
104
|
+
function applyResponsiveLayout(width: number): void {
|
|
107
105
|
// Report which zones FIT at this width. The store ANDs this with each zone's
|
|
108
106
|
// enabled flag and the user's desired visibility, so a disabled or
|
|
109
107
|
// intentionally-hidden zone is never force-shown just because there's room.
|
|
@@ -122,8 +120,11 @@ function applyResponsiveLayout(): void {
|
|
|
122
120
|
{ narrow: width < 100 },
|
|
123
121
|
);
|
|
124
122
|
}
|
|
125
|
-
|
|
126
|
-
process.stdout.
|
|
123
|
+
// Initial guess before the renderer exists; from then on the layout follows
|
|
124
|
+
// OpenTUI's own dimensions (see App). `process.stdout.columns` can lag behind
|
|
125
|
+
// the real size — e.g. a Windows Terminal tab that starts at a provisional
|
|
126
|
+
// size — which left zones overlapping until a manual resize.
|
|
127
|
+
applyResponsiveLayout(process.stdout.columns ?? 200);
|
|
127
128
|
|
|
128
129
|
// Land on the Today/Tomorrow panel by default — for a daily-planning tool the
|
|
129
130
|
// first question is "what's on my plate today", and that panel answers it.
|
|
@@ -170,6 +171,13 @@ function App() {
|
|
|
170
171
|
|
|
171
172
|
useKeyboard((key) => handleKey(store, key, plannerItems().length));
|
|
172
173
|
|
|
174
|
+
// Same width the frame is drawn at, updated on the renderer's resize events.
|
|
175
|
+
const dims = useTerminalDimensions();
|
|
176
|
+
createEffect(() => {
|
|
177
|
+
const { width } = dims();
|
|
178
|
+
if (width > 0) applyResponsiveLayout(width);
|
|
179
|
+
});
|
|
180
|
+
|
|
173
181
|
return (
|
|
174
182
|
<box
|
|
175
183
|
style={{
|
|
@@ -226,4 +234,6 @@ if (process.env.TUIBOARD_READY_FLAG) {
|
|
|
226
234
|
}
|
|
227
235
|
}
|
|
228
236
|
|
|
229
|
-
|
|
237
|
+
const renderer = await createCliRenderer({ useMouse: true });
|
|
238
|
+
registerRenderer(renderer);
|
|
239
|
+
await render(() => <App />, renderer);
|
package/src/config/loader.ts
CHANGED
|
@@ -59,6 +59,11 @@ export interface Config {
|
|
|
59
59
|
* Windows, `$SHELL` elsewhere). Ignored when `resumeCommand` is set.
|
|
60
60
|
*/
|
|
61
61
|
resumeShell: "auto" | Shell;
|
|
62
|
+
/**
|
|
63
|
+
* Agent status glyphs (config `status_indicators`): `symbols` (default —
|
|
64
|
+
* the same × ◐ ✓ ○ · herdr uses) or `dots` (● ○ ·).
|
|
65
|
+
*/
|
|
66
|
+
statusIndicators: "dots" | "symbols";
|
|
62
67
|
/**
|
|
63
68
|
* Template for the shell command copied to the clipboard by `c` in the agents
|
|
64
69
|
* zone — one paste that `cd`s into the session's directory and resumes it.
|
|
@@ -141,6 +146,7 @@ export const DEFAULT_CONFIG: Omit<Config, "root" | "loaded" | "boards"> = {
|
|
|
141
146
|
archiveColumn: "Archive",
|
|
142
147
|
resumeTerminal: "auto",
|
|
143
148
|
resumeShell: "auto",
|
|
149
|
+
statusIndicators: "symbols",
|
|
144
150
|
copyResumeCommand: DEFAULT_COPY_RESUME_COMMAND,
|
|
145
151
|
zones: { planner: "on", agenda: "on", agents: "on" },
|
|
146
152
|
};
|
|
@@ -216,6 +222,7 @@ interface RawConfig {
|
|
|
216
222
|
resume_command: string[];
|
|
217
223
|
resume_terminal: string;
|
|
218
224
|
resume_shell: string;
|
|
225
|
+
status_indicators: string;
|
|
219
226
|
copy_resume_command: string;
|
|
220
227
|
calendars: {
|
|
221
228
|
google?: {
|
|
@@ -367,6 +374,7 @@ function normalize(raw: Partial<RawConfig>, root: string, loaded: boolean): Conf
|
|
|
367
374
|
resumeShell: (SHELLS as readonly string[]).includes(raw.resume_shell ?? "")
|
|
368
375
|
? (raw.resume_shell as Shell)
|
|
369
376
|
: "auto",
|
|
377
|
+
statusIndicators: raw.status_indicators === "dots" ? "dots" : "symbols",
|
|
370
378
|
copyResumeCommand:
|
|
371
379
|
typeof raw.copy_resume_command === "string" &&
|
|
372
380
|
raw.copy_resume_command.trim().length > 0
|
package/src/input/handleKey.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* shares the same input contract.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { quitApp } from "~/app-exit";
|
|
16
17
|
import { isHiddenColumn } from "~/config/loader";
|
|
17
18
|
import {
|
|
18
19
|
LAUNCHER_NAME,
|
|
@@ -22,7 +23,9 @@ import {
|
|
|
22
23
|
runLaunchPlan,
|
|
23
24
|
systemLaunchEnv,
|
|
24
25
|
} from "~/input/open-session";
|
|
26
|
+
import { planHerdrFocus, planHerdrResume } from "~/input/herdr-open";
|
|
25
27
|
import { HARNESS, type AgentSession } from "~/store/agents";
|
|
28
|
+
import { herdrBin, herdrPlace } from "~/store/herdr";
|
|
26
29
|
import { googleTokenCanWrite } from "~/store/calendar";
|
|
27
30
|
import { isTask } from "~/parser/markdown";
|
|
28
31
|
import {
|
|
@@ -135,7 +138,7 @@ export function handleKey(
|
|
|
135
138
|
|
|
136
139
|
// Quit
|
|
137
140
|
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
138
|
-
|
|
141
|
+
void quitApp(() => store.dispose());
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
141
144
|
|
|
@@ -498,10 +501,16 @@ function handleAgentsZone(store: TuiStore, key: KeyEvent): void {
|
|
|
498
501
|
store.setCursor(0, Math.min(sessions.length - 1, ui.row + 1));
|
|
499
502
|
} else if (key.name === "k" || key.name === "up") {
|
|
500
503
|
store.setCursor(0, Math.max(0, ui.row - 1));
|
|
504
|
+
} else if (key.name === "H" || (key.name === "h" && key.shift)) {
|
|
505
|
+
// Jump to the session in herdr, or resume it there.
|
|
506
|
+
const target = sessions[ui.row];
|
|
507
|
+
if (target) void openInHerdr(store, target);
|
|
501
508
|
} else if (key.name === "enter" || key.name === "return") {
|
|
502
|
-
// Open (resume) the selected session in a new terminal tab/window
|
|
509
|
+
// Open (resume) the selected session in a new terminal tab/window —
|
|
510
|
+
// or, when it's already open in herdr, go there instead of a second copy.
|
|
503
511
|
const target = sessions[ui.row];
|
|
504
|
-
if (target) void
|
|
512
|
+
if (target?.herdr) void openInHerdr(store, target);
|
|
513
|
+
else if (target) void openSession(store, target);
|
|
505
514
|
} else if (key.name === "c") {
|
|
506
515
|
// Copy a one-paste "cd + resume" command for the selected session, so you
|
|
507
516
|
// can drop it into any tab/pane anywhere and land in the right directory
|
|
@@ -959,6 +968,46 @@ async function openSession(store: TuiStore, session: AgentSession): Promise<void
|
|
|
959
968
|
}
|
|
960
969
|
}
|
|
961
970
|
|
|
971
|
+
/**
|
|
972
|
+
* `H` in the Agents zone: focus the herdr pane the session is open in, or
|
|
973
|
+
* resume it in herdr — a new tab in the workspace that holds its project,
|
|
974
|
+
* started with `herdr agent start` (see herdr-open.ts).
|
|
975
|
+
*/
|
|
976
|
+
async function openInHerdr(store: TuiStore, session: AgentSession): Promise<void> {
|
|
977
|
+
const bin = herdrBin();
|
|
978
|
+
const snap = store.agents.herdr();
|
|
979
|
+
const { sessionId } = session;
|
|
980
|
+
if (!bin || !snap) {
|
|
981
|
+
const text = bin ? "herdr isn't running" : "herdr isn't installed (https://herdr.dev)";
|
|
982
|
+
store.setLastLaunch(sessionId, false, text);
|
|
983
|
+
store.flashBanner("warn", text);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
let steps;
|
|
987
|
+
let done: string;
|
|
988
|
+
if (session.herdr) {
|
|
989
|
+
steps = planHerdrFocus(bin, session);
|
|
990
|
+
done = `↗ ${herdrPlace(session.herdr)}`;
|
|
991
|
+
} else {
|
|
992
|
+
const plan = planHerdrResume(bin, session, snap);
|
|
993
|
+
steps = plan.steps;
|
|
994
|
+
done =
|
|
995
|
+
plan.where.kind === "new"
|
|
996
|
+
? `↗ Resumed in a new herdr workspace "${plan.where.label}"`
|
|
997
|
+
: `↗ Resumed in herdr ${plan.where.label}`;
|
|
998
|
+
store.flashBanner("info", `Starting ${HARNESS[session.provider].name} in herdr…`);
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
await runLaunchPlan(steps);
|
|
1002
|
+
store.setLastLaunch(sessionId, true, `${done}\n${describePlan(steps)}`);
|
|
1003
|
+
store.flashBanner("info", `${done} (${sessionId.slice(0, 8)})`);
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
const text = `herdr: ${e instanceof Error ? e.message : String(e)}`;
|
|
1006
|
+
store.setLastLaunch(sessionId, false, text);
|
|
1007
|
+
store.flashBanner("error", text);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
962
1011
|
/**
|
|
963
1012
|
* Cross-platform clipboard copy. Picks the host's native cli tool:
|
|
964
1013
|
* Windows → clip
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open agent sessions inside herdr (the `H` key, and Enter for sessions
|
|
3
|
+
* already open there): focus the pane a session lives in, or resume it in a
|
|
4
|
+
* new tab of the workspace that holds its project, started through
|
|
5
|
+
* `herdr agent start` so herdr tracks it as an agent from the first second.
|
|
6
|
+
*
|
|
7
|
+
* Planning is pure; the steps run through `runLaunchPlan`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { LaunchStep } from "~/input/open-session";
|
|
13
|
+
import type { AgentSession } from "~/store/agents";
|
|
14
|
+
import { HERDR_AGENT, samePath, type HerdrSnapshot } from "~/store/herdr";
|
|
15
|
+
|
|
16
|
+
/** Focus the herdr pane a session is open in. */
|
|
17
|
+
export function planHerdrFocus(bin: string, session: AgentSession): LaunchStep[] {
|
|
18
|
+
if (!session.herdr) throw new Error("session isn't open in herdr");
|
|
19
|
+
return [{ cmd: bin, args: ["agent", "focus", session.herdr.paneId] }];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type WorkspaceChoice =
|
|
23
|
+
| { kind: "existing"; workspaceId: string; label: string; why: "cwd" | "name" | "focused" }
|
|
24
|
+
| { kind: "new"; label: string };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a session should open: the workspace already holding a pane in its
|
|
28
|
+
* directory, else one named like the directory, else the focused one — or a
|
|
29
|
+
* new workspace named after the directory when herdr has none.
|
|
30
|
+
*/
|
|
31
|
+
export function chooseWorkspace(snap: HerdrSnapshot, cwd: string): WorkspaceChoice {
|
|
32
|
+
const label = (id: string) => snap.workspaces.get(id)?.label ?? id;
|
|
33
|
+
const byCwd = snap.panes.find((p) => p.workspaceId && samePath(p.cwd, cwd));
|
|
34
|
+
if (byCwd) return { kind: "existing", workspaceId: byCwd.workspaceId, label: label(byCwd.workspaceId), why: "cwd" };
|
|
35
|
+
const folder = basename(cwd.replaceAll("\\", "/")).toLowerCase();
|
|
36
|
+
for (const [id, w] of snap.workspaces) {
|
|
37
|
+
if (folder && w.label.toLowerCase() === folder) return { kind: "existing", workspaceId: id, label: w.label, why: "name" };
|
|
38
|
+
}
|
|
39
|
+
const focused = snap.focusedWorkspaceId ?? [...snap.workspaces.keys()][0];
|
|
40
|
+
if (focused) return { kind: "existing", workspaceId: focused, label: label(focused), why: "focused" };
|
|
41
|
+
return { kind: "new", label: basename(cwd.replaceAll("\\", "/")) || "agents" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pane id from a `tab create` / `workspace create` response. */
|
|
45
|
+
function rootPaneId(out: string): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const r = JSON.parse(out)?.result;
|
|
48
|
+
return r?.root_pane?.pane_id ?? r?.pane?.pane_id ?? r?.tab?.root_pane?.pane_id;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Tab titles: short, single line. */
|
|
55
|
+
function tabLabel(name: string): string {
|
|
56
|
+
const line = name.split("\n")[0]!.trim();
|
|
57
|
+
return line.length > 32 ? `${line.slice(0, 31).trimEnd()}…` : line || "agent";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* herdr agent names: lowercase letter first, then `[a-z0-9_-]`, ≤ 32 chars.
|
|
62
|
+
* Title slug + short session id, so two sessions with the same title differ.
|
|
63
|
+
*/
|
|
64
|
+
export function herdrAgentName(title: string, sessionId: string, fallback: string): string {
|
|
65
|
+
const id = sessionId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(-6);
|
|
66
|
+
const slug = title
|
|
67
|
+
.split("\n")[0]!
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.normalize("NFKD")
|
|
70
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
71
|
+
.replace(/^[^a-z]+/, "")
|
|
72
|
+
.replace(/-+$/, "")
|
|
73
|
+
.slice(0, 32 - id.length - 1)
|
|
74
|
+
.replace(/-+$/, "");
|
|
75
|
+
return `${slug || fallback}-${id}`.slice(0, 32);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resume a session in herdr: new tab (or workspace) in the session's
|
|
80
|
+
* directory, then start the agent there.
|
|
81
|
+
*
|
|
82
|
+
* POSIX: `herdr agent start --kind <agent> -- <resume args>`, so herdr tracks
|
|
83
|
+
* it from the first second. Windows: herdr launches the agent's executable
|
|
84
|
+
* directly, which fails for npm-installed CLIs (codex, opencode, pi are an
|
|
85
|
+
* extensionless sh shim next to `.cmd`/`.ps1` — "not a valid Win32
|
|
86
|
+
* application"), so the resume command is typed into the pane's shell
|
|
87
|
+
* instead (`herdr pane run`), which resolves it through PATHEXT; herdr still
|
|
88
|
+
* detects the agent from the running process.
|
|
89
|
+
*/
|
|
90
|
+
export function planHerdrResume(
|
|
91
|
+
bin: string,
|
|
92
|
+
session: AgentSession,
|
|
93
|
+
snap: HerdrSnapshot,
|
|
94
|
+
platform: NodeJS.Platform = process.platform,
|
|
95
|
+
): { steps: LaunchStep[]; where: WorkspaceChoice } {
|
|
96
|
+
const where = chooseWorkspace(snap, session.cwd);
|
|
97
|
+
const label = tabLabel(session.displayName);
|
|
98
|
+
const create: LaunchStep =
|
|
99
|
+
where.kind === "existing"
|
|
100
|
+
? {
|
|
101
|
+
cmd: bin,
|
|
102
|
+
args: ["tab", "create", "--workspace", where.workspaceId, "--cwd", session.cwd, "--label", label, "--focus"],
|
|
103
|
+
captureId: rootPaneId,
|
|
104
|
+
undo: { cmd: bin, args: ["pane", "close", "{id}"] },
|
|
105
|
+
}
|
|
106
|
+
: {
|
|
107
|
+
cmd: bin,
|
|
108
|
+
args: ["workspace", "create", "--cwd", session.cwd, "--label", where.label, "--focus"],
|
|
109
|
+
captureId: rootPaneId,
|
|
110
|
+
undo: { cmd: bin, args: ["pane", "close", "{id}"] },
|
|
111
|
+
};
|
|
112
|
+
if (platform === "win32") {
|
|
113
|
+
return {
|
|
114
|
+
steps: [create, { cmd: bin, args: ["pane", "run", "{id}", session.resumeCommand] }],
|
|
115
|
+
where,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const [, ...agentArgs] = session.resumeArgv;
|
|
119
|
+
const start: LaunchStep = {
|
|
120
|
+
cmd: bin,
|
|
121
|
+
args: [
|
|
122
|
+
"agent", "start", herdrAgentName(session.displayName, session.sessionId, HERDR_AGENT[session.provider]),
|
|
123
|
+
"--kind", HERDR_AGENT[session.provider],
|
|
124
|
+
"--pane", "{id}",
|
|
125
|
+
"--timeout", "30000",
|
|
126
|
+
"--", ...agentArgs,
|
|
127
|
+
],
|
|
128
|
+
// herdr waits for the agent to be ready (up to the --timeout above).
|
|
129
|
+
timeoutMs: 40_000,
|
|
130
|
+
};
|
|
131
|
+
return { steps: [create, start], where };
|
|
132
|
+
}
|
|
@@ -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}{" "}
|
package/src/ui/AgentsBar.tsx
CHANGED
package/src/ui/Modal.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { AGENDA_WIDTH } from "~/ui/layout";
|
|
|
24
24
|
import { formatHm } from "~/store/timeline";
|
|
25
25
|
import { HARNESS } from "~/store/agents";
|
|
26
26
|
import { HARNESS_COLOR } from "~/ui/AgentRow";
|
|
27
|
+
import { herdrPlace } from "~/store/herdr";
|
|
27
28
|
import type { TuiStore } from "~/store/index";
|
|
28
29
|
import type { PriorityLevel, TimeBlock } from "~/types";
|
|
29
30
|
|
|
@@ -936,6 +937,27 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
|
|
|
936
937
|
<span style={{ fg: T.textDim }}>status </span>
|
|
937
938
|
<span style={{ fg: T.text }}>{s().status}</span>
|
|
938
939
|
</text>
|
|
940
|
+
<Show when={s().herdr}>
|
|
941
|
+
{(h: () => NonNullable<ReturnType<typeof s>["herdr"]>) => (
|
|
942
|
+
<>
|
|
943
|
+
<text>
|
|
944
|
+
<span style={{ fg: T.textDim }}>herdr </span>
|
|
945
|
+
<span style={{ fg: T.accent }}>{herdrPlace(h())}</span>
|
|
946
|
+
<span style={{ fg: T.textDim }}>
|
|
947
|
+
{" "}pane {h().paneId} · {h().status} · matched by {h().matchedBy}
|
|
948
|
+
</span>
|
|
949
|
+
</text>
|
|
950
|
+
<Show when={h().matchedBy === "cwd"}>
|
|
951
|
+
<text wrapMode="word">
|
|
952
|
+
<span style={{ fg: T.textDim }}>
|
|
953
|
+
{" "}herdr doesn't report which {h().agent} session this pane is on — run
|
|
954
|
+
`herdr integration install {h().agent}` for an exact match.
|
|
955
|
+
</span>
|
|
956
|
+
</text>
|
|
957
|
+
</Show>
|
|
958
|
+
</>
|
|
959
|
+
)}
|
|
960
|
+
</Show>
|
|
939
961
|
<text>
|
|
940
962
|
<span style={{ fg: T.textDim }}>cwd </span>
|
|
941
963
|
<span style={{ fg: T.text }}>{s().cwd}</span>
|
|
@@ -1126,7 +1148,8 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
1126
1148
|
emoji: "🤖",
|
|
1127
1149
|
title: "Agents zone",
|
|
1128
1150
|
rows: [
|
|
1129
|
-
["Enter", "Open (resume) the selected session in a new terminal tab/window"],
|
|
1151
|
+
["Enter", "Open (resume) the selected session in a new terminal tab/window — or go to it if it's open in herdr"],
|
|
1152
|
+
["H", "herdr: go to the session, or resume it in a new tab of its project's workspace"],
|
|
1130
1153
|
["c", "Copy a 'cd + resume' command for the session"],
|
|
1131
1154
|
["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
|
|
1132
1155
|
["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
|
package/src/views/AgentsOnly.tsx
CHANGED
|
@@ -93,6 +93,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
93
93
|
cursor={isActive() && i() === agentRow()}
|
|
94
94
|
nameMaxChars={120}
|
|
95
95
|
variant="card"
|
|
96
|
+
indicators={props.store.agentIndicators}
|
|
96
97
|
onClick={() => {
|
|
97
98
|
props.store.setActiveZone("agents");
|
|
98
99
|
props.store.setCursor(0, i());
|