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
|
@@ -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,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.1] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Agents list stays in order when sessions move** (#52). A new session
|
|
14
|
+
appearing on top could show twice, leave blank rows, or draw rows in the
|
|
15
|
+
wrong order past the panel border; the lists now keep one stable row per
|
|
16
|
+
position. The cursor also stays on the session you selected while the list
|
|
17
|
+
reorders, instead of silently landing on another one (so Enter, `H` and `c`
|
|
18
|
+
act on the session you picked).
|
|
19
|
+
- **`--view=agents` / `--view=timeline` / `--view=board` start on their own
|
|
20
|
+
zone** (#54), so the cursor and keys work right away instead of acting on
|
|
21
|
+
the hidden planner.
|
|
22
|
+
|
|
23
|
+
## [0.13.0] - 2026-09-17
|
|
24
|
+
|
|
25
|
+
tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
|
|
26
|
+
herdr's live state for every session open there, speaks herdr's status
|
|
27
|
+
symbols, and `H` jumps to a session in herdr or resumes it in the right
|
|
28
|
+
workspace. Plus fixes found using it all day on Windows.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **Live agent state from herdr** (#37). When herdr is running, tuiboard polls
|
|
32
|
+
`herdr api snapshot` and links every pane running Claude Code, Codex,
|
|
33
|
+
OpenCode or Pi to its session — by the session id/file herdr reports (with
|
|
34
|
+
herdr's agent integrations installed), else by agent and directory. Those
|
|
35
|
+
sessions take herdr's state, including two new ones: **waiting for you** and
|
|
36
|
+
**done**; idle Codex/OpenCode/Pi sessions become visible, and long Claude
|
|
37
|
+
turns no longer show as stale (#22). Zoomed cards and the `o` detail show the
|
|
38
|
+
herdr workspace and tab.
|
|
39
|
+
- **herdr's status symbols** — `×` waiting, `◐` working, `✓` done, `○` idle,
|
|
40
|
+
`·` closed (tuiboard's own `△` for stale) — are the default for everyone, so
|
|
41
|
+
both tools read the same; `status_indicators: dots` keeps colored dots.
|
|
42
|
+
- **`H` opens sessions in herdr** (#38): focuses the pane a session is open
|
|
43
|
+
in, or resumes it in herdr — a new tab named after the session, in the
|
|
44
|
+
workspace that already holds that directory (else one named like the
|
|
45
|
+
folder, else the focused one). **Enter** on a session already open in herdr
|
|
46
|
+
focuses it instead of starting a second copy.
|
|
47
|
+
- **Omarchy bar widget in the repo** (#27): `omarchy-plugin/` — an
|
|
48
|
+
overdue/today badge and a Today/Tomorrow panel (done, undone, defer, open
|
|
49
|
+
tuiboard) built on `tuiboard summary` / `tuiboard task`, installed with
|
|
50
|
+
`omarchy-plugin/install.sh`. Repo only, not part of the npm package.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
- **Agent sessions are sorted by most recent activity** (#43), newest first,
|
|
54
|
+
instead of by status first — a session used 40 seconds ago no longer sits
|
|
55
|
+
below ones idle in herdr for hours. Status only breaks ties; archived
|
|
56
|
+
sessions stay at the bottom.
|
|
57
|
+
- Launch steps for Enter and `H` run asynchronously: a slow start (cold
|
|
58
|
+
PowerShell, an agent booting in herdr) no longer freezes the UI.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
- **Quitting gives the terminal back** (#47). `q`, Ctrl+C and termination
|
|
62
|
+
signals exited without tearing down the renderer, leaving mouse tracking
|
|
63
|
+
and the alternate screen on — moving the mouse at the shell then printed
|
|
64
|
+
`51;7;45M…`. tuiboard now restores the terminal before exiting, and the
|
|
65
|
+
`tuiboard` launcher resets mouse/paste/focus reporting and the cursor after
|
|
66
|
+
the app ends, whatever way it ended.
|
|
67
|
+
- **`H` on Windows with Codex, OpenCode and Pi** (#41). npm-installed CLIs on
|
|
68
|
+
Windows are an extensionless sh shim next to `.cmd`/`.ps1`, which herdr
|
|
69
|
+
can't launch directly ("not a valid Win32 application"); on Windows the
|
|
70
|
+
resume command is typed into the new pane's shell instead.
|
|
71
|
+
- **Responsive layout follows the renderer's terminal size** (#45), the size
|
|
72
|
+
the frame is drawn at, instead of `process.stdout.columns`.
|
|
73
|
+
|
|
74
|
+
### Known issues
|
|
75
|
+
- On Windows, herdr may not detect Pi sessions as agents (#49): they open
|
|
76
|
+
fine, but without herdr's live state.
|
|
77
|
+
|
|
10
78
|
## [0.12.0] - 2026-09-17
|
|
11
79
|
|
|
12
80
|
The Agents zone stops being Claude-Code-only: it now lists **Codex, OpenCode
|
|
@@ -419,6 +487,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
419
487
|
|
|
420
488
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
421
489
|
|
|
490
|
+
[0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
|
|
491
|
+
[0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
|
|
422
492
|
[0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
|
|
423
493
|
[0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
|
|
424
494
|
[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.1",
|
|
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.
|
|
@@ -147,6 +148,11 @@ if (view === "planner") {
|
|
|
147
148
|
store.setActiveZone("planner");
|
|
148
149
|
store.setZoomed(true);
|
|
149
150
|
}
|
|
151
|
+
// The single-zone views show one zone; keys must act on it. Without this the
|
|
152
|
+
// planner (the default landing zone above) stayed active behind them.
|
|
153
|
+
if (view === "agents" || view === "timeline" || view === "board") {
|
|
154
|
+
store.setActiveZone(view);
|
|
155
|
+
}
|
|
150
156
|
|
|
151
157
|
// ─── App shell ──────────────────────────────────────────────────────────────
|
|
152
158
|
|
|
@@ -170,6 +176,13 @@ function App() {
|
|
|
170
176
|
|
|
171
177
|
useKeyboard((key) => handleKey(store, key, plannerItems().length));
|
|
172
178
|
|
|
179
|
+
// Same width the frame is drawn at, updated on the renderer's resize events.
|
|
180
|
+
const dims = useTerminalDimensions();
|
|
181
|
+
createEffect(() => {
|
|
182
|
+
const { width } = dims();
|
|
183
|
+
if (width > 0) applyResponsiveLayout(width);
|
|
184
|
+
});
|
|
185
|
+
|
|
173
186
|
return (
|
|
174
187
|
<box
|
|
175
188
|
style={{
|
|
@@ -226,4 +239,6 @@ if (process.env.TUIBOARD_READY_FLAG) {
|
|
|
226
239
|
}
|
|
227
240
|
}
|
|
228
241
|
|
|
229
|
-
|
|
242
|
+
const renderer = await createCliRenderer({ useMouse: true });
|
|
243
|
+
registerRenderer(renderer);
|
|
244
|
+
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
|
+
}
|