Herdr — Rust terminal multiplexer + AI-agent orchestrator (trial)¶
herdrdev/herdr is a Rust terminal multiplexer with built-in coding-agent awareness (it tracks per-pane agent state: idle / working / blocked / done). It sits in the same niche as tmux/zellij but is mouse-first and agent-native. Docs: https://herdr.dev/docs/.
This repo ships herdr as a trial tool that coexists with tmux — you run herdr or tmux, never nested. Nothing about the existing tmux / sesh / tmuxp / workmux setup changes; herdr is purely additive so you can evaluate it without losing your daily driver.
- Install:
- Both platforms — GitHub release single static binary (
herdr-{linux,macos}-{x86_64,aarch64}) into~/.local/bin/herdr, managed by the# --- herdr ... ---block indot_ansible/roles/devtools/tasks/main.yml. No tarball, so no unarchive step. - macOS is deliberately not Homebrew, and it is the only tool in this repo where that's true. Upstream disables
herdr updateon Homebrew/mise/Nix installs because the package manager owns the binary — which removes the only pane-preserving upgrade path and leaves a server restart, i.e. killing every pane process, as the sole option. The formula was dropped in favour of the release binary so macOS gets--handofftoo. Boxes provisioned before the switch are migrated by a one-timebrew uninstall herdrin the same role (leaving both installed would put two binaries onPATHand risk a client/serverprotocol_mismatchagainst yourself). - Verify:
herdr --version· validate config withherdr server reload-config - Agent skill: every
chezmoi applyinstalls the official global skill fromherdr --skillinto~/.agents/skills/herdr/SKILL.md, with a Claude discovery symlink. The binary is the version authority; the Git-vendored catalog copy is deliberately not used at runtime. - Upgrade:
just upgrade-herdr(→herdr update --handoff, then refresh the binary-matched skill) on both platforms — run it from outside herdr, see below
herdr updaterefuses to run from inside a herdr pane. The handoff replaces the server process that owns the pane you are typing in, so it fails closed:$ herdr update --handoff # from a pane inside herdr update failed: run `herdr update` outside herdr after detaching from the sessionThe flow is detach (
prefix+q) → run it in a plain terminal → reattach with bareherdr. Detaching does not kill anything (client close ≠ session loss), and--handoffthen swaps the server without exiting pane processes — verified 2026-07 upgrading 0.7.1 → 0.7.5 with a Claude Code session running in a pane, which survived uninterrupted.just upgrade-herdrencodes this: it skips with instructions rather than failing when it detectsHERDR_ENV/HERDR_PANE_ID, sojust upgrade-allfrom a herdr pane doesn't die here. Full trace:pitfalls/herdr-update-handoff-refuses-inside-pane.md.Install is version-blind; upgrades are yours to run. The Linux ansible task gates on
herdr --versionreturning non-zero — "is it installed at all", not "is it current" — so a fresh box gets whatever is latest that day andchezmoi applynever touches it again. This is the repo's install-vs-upgrade split working as designed, but herdr drifts faster than most because its config can outrun the binary (a[[keys.command]]usingtype = "popup", added in 0.7.4, makes an older herdr reject the entire keys block —reload-configreportsstatus: "partial"+keeping current keys settings, and only that one diagnostic line tells you).The app version and integration schema version are separate. The current binary is Herdr 0.8.0;
current (v7)/outdated (v6 < v7)fromherdr integration statusrefers to the Codex integration schema, not a downgraded Herdr binary. Reinstall stale integrations withherdr integration install <agent>. Herdr owns the generated scripts (~/.codex/herdr-agent-state.sh, etc.); Codex's~/.codex/hooks.jsonis a hook-aware chezmoimodify_target that preserves Herdr's entry beside peon/CodeIsland hooks.just upgrade-herdrreports stale integrations but deliberately does not reinstall them automatically. - Config:~/.config/herdr/config.toml— chezmoimodify_overlay (dot_config/herdr/modify_config.toml.tmpl+ managed body in.chezmoitemplates/herdr/config.toml). The overlay enforces our managed tables on everychezmoi applywhile preserving all other live/user-owned top-level configuration, including Herdr's current runtime writes (see Config management).Why a package-managed herdr strands its own server — the reason macOS left Homebrew. herdr's socket API is protocol-versioned, and a package-manager upgrade cannot restart the server, so after
brew upgrade herdrevery CLI call (and therefore everytv herdr-*channel,hvibe/hcode, and[[keys.command]]helper) failsprotocol_mismatchuntil the server restarts — which kills all pane processes.herdr update --handoff, the live pane-preserving path, is disabled on Homebrew/mise/Nix installs, so a brew-installed herdr has no way to avoid that restart. This repo now installs the self-managed release binary on macOS too, which is what closes the gap; the pitfall below is kept because it still describes what happens on any box that has not been migrated (andjust upgrade-herdrskips with instructions when it detects a package-managed install). Check withherdr status(compatible:/restart_needed:), notherdr --version. Full recovery matrix:pitfalls/herdr-brew-upgrade-strands-running-server.md.Not gated by
enableVimMode. That flag governs shell + tmux modal editing; herdr's copy mode (prefix+[) is vi-style natively regardless (h/j/k/l,w/b/e,{/},v/Space to select,y/Enter to copy,q/Esc to leave), so there is nothing to gate.
Browser tools¶
For visible browser work beside an agent, use
terminal-browser inside a graphics-capable outer terminal
such as Ghostty. Its own CLI handles pane splitting; no Herdr browser plugin is
required. Browser skills come from the installed browser package, separately
from herdr --skill. Playwright CLI handles independent headless testing.
Model differences vs tmux¶
herdr's hierarchy is Session → Workspace → Tab → Pane — one level deeper than tmux (Session → Window → Pane). A "Workspace" is a project-level container; a "Tab" groups panes. The CLI (herdr session|workspace|tab|pane|agent …, most with --json) is the scripting surface that replaces tmux switch-client / list-sessions etc.
The important twist vs tmux: a single herdr server hosts multiple named sessions, each its own persistent tree with its own socket. That top Session level is closer to "multiple tmux servers / sockets" than to a tmux session — the tmux thing you'd call a session (vibe/<repo>) maps to a herdr Workspace, not a herdr Session.
Named sessions¶
herdr— launch or attach the default session (socket~/.config/herdr/herdr.sock).herdr --session NAME— launch/attach a named session (socket~/.config/herdr/sessions/<NAME>/herdr.sock).herdr session list [--json]— all sessions withrunning+socket_path. The--jsonsocket_pathis authoritative — the plain-text table printsherdr.socket, but the real socket isherdr.sock.herdr session attach NAME·herdr session stop NAME·herdr session delete NAME(defaultis a validNAMEfor stop).
Targeting a session from the CLI subcommands: there is no --session/--socket flag on workspace/tab/pane/agent. The only lever is the HERDR_SOCKET_PATH env var — set it to a session's socket_path and every subcommand routes there. Inside a herdr pane it is already exported to the current session's socket, so scripts run inside herdr target the current session for free. This is exactly how hvibe --session NAME works (see below).
Search pane content with herdr-grep¶
Herdr can read one pane but has no native cross-pane grep. This repo deploys herdr-grep, which packages pane list → pane read → rg and prints the exact Session / Workspace / Tab / Pane containing each match:
$ herdr-grep -F 'connection refused'
[session=default workspace=w1 tab=w1:t2 pane=w1:p4] 183:connection refused while opening socket
herdr-grep 'error|failed' # regex; current/default session
herdr-grep -F -i 'connection refused' # literal, case-insensitive
herdr-grep --visible 'ready' # current visible screens only
herdr-grep --source recent-unwrapped 'url' # retained history without hard wraps
herdr-grep --session work 'panic' # one running named session
herdr-grep --all-sessions 'rate limit' # every running local session
herdr-grep --all-sessions --json 'panic' # structured matches + errors
herdr-grep --pick 'error|failed' # grep first, then fzf-pick and jump/attach
herdr-grep --pick --all-sessions 'panic' # outside Herdr: pick, pre-focus, attach session
herdr-grep -F -- -leading-dash # protect a pattern beginning with `-`
| Aspect | Behavior |
|---|---|
| Default scope | The ambient session when HERDR_SOCKET_PATH is set (inside Herdr); otherwise default. --session NAME resolves the authoritative socket from herdr session list --json; --all-sessions scans running sessions only. An ambient socket that cannot be mapped uniquely returns 2 rather than being mislabeled. |
| Content source | recent by default (full retained scrollback); --visible for the current screen; --source recent-unwrapped when terminal wrapping splits a phrase. |
| Output | Human output repeats session/workspace/tab/pane on every matching line. --json adds socket, cwd, agent state, byte-offset submatches, complete, and structured errors. Line numbers are relative to this capture, not persistent pane coordinates. |
| Interactive picker | --pick PATTERN sends the filtered matches to fzf. Enter exact-focuses the pane; outside Herdr it then attaches the selected session. Alt+S reruns the same pattern against recent-unwrapped; Alt+V returns to visible. |
| Exit status | 0 = match + complete scan; 1 = clean no-match; 2 = usage/error/incomplete scan. If a pane disappears mid-scan, good matches are preserved, the failure goes to stderr/JSON, and the command returns 2. |
Inside Herdr, prefix+Alt+F runs herdr-grep --pick --visible: type the grep pattern once, then use fzf to refine the already-filtered matches. Agent panes use Herdr's exact agent focus; ordinary split panes are reached through a validated shortest path of directional pane neighbor / pane focus calls, with final active workspace/tab plus pane layout.focused_pane_id verification. The picker refuses a different-session selection while already attached (detach first rather than nest). From a normal shell, --pick --all-sessions pre-focuses the selected target and then attaches its session, matching hhere's focus-then-attach model. Preview line numbers refer to the earlier capture and may drift while the pane keeps printing.
The search is bounded by what the live Herdr server still retains: closed panes, output older than the history limit, and alternate-screen content already discarded cannot be recovered. The command runs where the Unix socket is accessible. For a remote server, run the deployed CLI through SSH, for example ssh server 'herdr-grep --all-sessions -F -- "connection refused"'; herdr --remote is an interactive thin-client attach, not an RPC prefix for pane subcommands.
tv herdr-panes and tv herdr-review still fuzzy-search metadata only (pane/session identifiers, state, cwd). Their visible pane text is a preview and is not part of Television's searchable source. Television runs its source before—and cannot receive—the live query, so content selection deliberately uses herdr-grep --pick → fzf instead of a tv herdr-grep channel.
cwd & workspace-naming model¶
herdr tracks cwd differently from tmux, which trips up two common expectations (all verified via herdr pane list):
- Every pane has two cwds.
cwd= the shell's startup directory (fixed at spawn);foreground_cwd= the live cwd, tracked via OSC 7 shell integration. Acdin the shell updatesforeground_cwd; the startupcwdnever changes. cdinside a child process / subshell doesn't propagate. Because tracking is OSC 7-based, acdin a subshell that doesn't re-emit OSC 7 — e.g.chezmoi cd, which spawns a new shell in the source dir — is invisible to herdr.foreground_cwdstays put, so the space's git-repo detection and theprefix+Glazygit location don't follow the subshell. This is inherent to OSC 7, not a herdr bug — expected behavior.- New tabs follow the focused pane's live cwd (herdr ≥0.7.x). With
new_cwd = "follow"(below), a new tab inherits the focused pane's live cwd — same as a split. herdr issue #912 changedfollowso tabs behave like splits; the older "new tab opens at the workspace root (often$HOME)" was treated as a bug and removed. There is nonew_cwdvalue (and no workspace-level cwd —herdr workspace getexposes none) that opens a new tab at the workspace root; for that useprefix+C(below). - The workspace ("space") label auto-follows the root/primary pane's live cwd basename (e.g. →
chezmoi,trading-journal).cdin tab 1 renames the space;cdin other tabs does not. No config knob controls this. - A relative
--cwdis resolved by the SERVER, and a miss falls back to$HOMEsilently.herdr workspace create --cwd ../foojoins../footo the directoryherdr serverwas launched from — not to your shell's$PWD— and when that path doesn't exist herdr returns{"result":{"type":"ok"}}with the pane opened at$HOME. No error, no warning. tmux is the opposite:new-session -c ../foois resolved client-side, which is why theshere/scode/svibeoriginals never needed a guard and their herdr ports did. Any user-supplied path must be absolutized in the calling shell before it reaches the CLI —24_herdr.shdoes this in_herdr_abs_dir. Seepitfalls/hhere-p-relative-path-opens-workspace-at-home.md.
new_cwd values ([terminal]) — the CWD policy for new panes/tabs/workspaces when no explicit --cwd is given:
| value | meaning |
|---|---|
follow (default) |
inherit the source pane — for both a split and a new tab, the focused pane's live cwd (herdr ≥0.7.x) |
home |
$HOME |
current |
herdr's own process directory (NOT the focused pane) |
"~/path" |
a fixed path |
No new_cwd value opens a new tab at the workspace ("space") root — follow now tracks the focused pane, and herdr exposes no workspace-level cwd field. This repo ships that behavior as a keybind: prefix+C → ~/.config/herdr/new-tab-at-space-root.sh (dot_config/herdr/executable_new-tab-at-space-root.sh), which derives the space root — the lowest-numbered tab's pane live cwd, i.e. what herdr uses for the space label — and runs herdr tab create --workspace <wid> --cwd <root> --focus. Native prefix+c and the mouse "+" button keep the follow-focused-pane behavior; a keybind cannot intercept the mouse button.
[[keys.command]]
key = "prefix+C"
type = "pane"
command = "~/.config/herdr/new-tab-at-space-root.sh \"$HERDR_ACTIVE_PANE_ID\""
description = "new tab at the workspace (space) root dir"
Feasibility matrix (current tmux experience → herdr)¶
| Current capability | herdr story | How it's handled here |
|---|---|---|
| Catppuccin theme + light/dark | Native [theme] + auto_switch |
Fixed Mocha in the managed config (auto_switch = false) |
| Splits / zoom / new tab+workspace / pane nav | Native [keys] actions |
Rebound to tmux muscle memory |
| Session persistence (resurrect/continuum) | Native detach/reattach | Skipped — native |
| Mouse / right-click menus | Native mouse-first | Skipped — native |
| Agent status 🤖/💬/✅ (workmux, 6 files) | Native agent-state rollups in sidebar | Skipped — native (workmux untouched for tmux). Panel ordered as an attention queue, not grouped by space: ui.agent_panel_sort = "priority" (herdr's default is "spaces") |
sesh fuzzy switch + tmuxp layouts |
Plugin herdr-plus Projects + Quick Actions | Plugin + Projects templates |
tv channel popups (prefix+T/U/a) |
Custom command panes ([[keys.command]] type="pane") |
Key bindings + 2 herdr-aware channels |
| dev / lazygit / scratch launchers | Custom command panes + popups | Full-screen TUIs such as lazygit stay temporary panes; short-lived modal tasks use popups |
URL picker (prefix+u, tmux-fzf-url) |
Custom command pane + helper | prefix+u → url-pick.sh (fzf → x open); --source recent scans scrollback |
| Translation | No tmux analog | prefix+t opens the interactive translate TUI; prefix+y Quick Actions capture a pane for bilingual translation |
File-path picker (prefix+p; extrakto prefix+Tab on tmux) |
Custom command pane + helper | prefix+p → path-pick.sh — two-tier (exists-under-cwd first) → x copy |
| Neovim clipboard across local/remote attaches | OSC 52 write only; clipboard queries unsupported | yanks reach the attached client; normal p uses Neovim's register; inbound external text uses terminal paste |
| Search all pane content + jump | CLI pipeline + fzf + exact-focus helper | prefix+Alt+F → herdr-grep --pick --visible; Alt+S searches unwrapped scrollback |
Seamless Ctrl-hjkl nvim↔pane nav |
No herdr-aware smart-splits | Gap — workaround below |
OSC133 copy-mode (cpout/cpblock) |
tmux-specific | Gap — cpcmd (zsh history) still works |
| Per-window status glyphs + bookmarks ⭐📌 | Partial — report-metadata --token (per-pane metadata tokens, orthogonal to agent state) |
Review-pending flag (hmark/prefix+m + tv herdr-review inbox); decorative status-bar glyphs still a gap (no format-string interpolation) |
| AI session-summary / agent-wakeup capture | agent-wakeup uses native agent list/get/read and stable agent-session IDs |
Shipped — dual tmux/Herdr status, scheduling, preview, and safe send |
Keybindings¶
Prefix is ctrl+b (same as tmux). Built-in actions can only be rebound (herdr's action set is fixed); everything else is a [[keys.command]] custom command. Custom commands receive $HERDR_SOCKET_PATH, $HERDR_ACTIVE_PANE_ID, $HERDR_ACTIVE_PANE_CWD and run from the focused pane's cwd.
Two families of herdr env vars. The
HERDR_ACTIVE_PANE_*vars above are injected only into[[keys.command]]invocations. Every shell running inside a herdr pane also gets an ambient set:HERDR_ENV=1,HERDR_PANE_ID,HERDR_TAB_ID,HERDR_WORKSPACE_ID, andHERDR_SOCKET_PATH(the current session's socket). Scripts useHERDR_ENVas the "am I inside herdr?" test and inheritHERDR_SOCKET_PATHto target the current session — that's whathvibe/hcoderely on.Pane env is frozen at herdr-server start. Unlike tmux (
update-environment), herdr does not refreshSSH_CONNECTION/DISPLAY/WAYLAND_DISPLAY/SSH_AUTH_SOCKon a new client attach — every pane inherits whatever the daemon had when it first started. Practical fallout: over SSH a pane's$SSH_*is empty while$WAYLAND_DISPLAYstill points at a stale local display, so clipboard tools that pick a backend by those vars copy to the wrong machine.xand Neovim work around it by keying onHERDR_ENV(→ OSC 52 via the pane TTY, which does proxy to the real client). Neovim deliberately uses that path for copy only: Herdr does not forward OSC 52 clipboard-query responses, so a fullunnamedplusprovider makes ordinarypwait for up to ten seconds. Use normalpfor editor registers and terminal paste for inbound clipboard text. Seepitfalls/x-copy-over-ssh-writes-remote-clipboard-not-osc52.md,pitfalls/nvim-p-waits-for-osc52-response-in-herdr.md, and clipboard.md. For an agent forwarded viaSSH_AUTH_SOCK, re-export it per pane or start the server from a session that has it.
| Key | Action | Type |
|---|---|---|
prefix + c / prefix + 1..9 |
new tab / switch tab | built-in default |
prefix + C |
new tab at the workspace (space) root dir — prefix+c + mouse "+" stay follow-focused-pane |
command pane |
prefix + h/j/k/l |
focus pane | built-in default |
prefix + \| / prefix + % · prefix + minus / prefix + " |
split side-by-side / stacked (tmux muscle memory — both the intuitive key and the tmux default) | rebound (arrays) |
prefix + z / prefix + x |
zoom / close pane | built-in default |
prefix + w / prefix + g |
workspace navigator (navigate-mode: j/k or arrows to move, Enter to pick) / session navigator (in-popup keys) |
built-in; navigate_workspace_* rebound to j/k + arrows |
prefix + ctrl + 1..9 |
jump directly to workspace N (switch_workspace) |
rebound (indexed) |
prefix + alt + 1..9 |
jump directly to agent N's pane (focus_agent) |
rebound (indexed) |
Why ctrl/alt, not shift, for the indexed jumps: under the Kitty keyboard protocol (which herdr uses)
shift+1still carries the printable!, so herdr matches the symbol rather thanshift+1—prefix+shift+1..9silently does nothing.ctrl+digit/alt+digithave no printable form and disambiguate cleanly. |prefix + [| vi copy mode (hjkl,w/b/e,{/},v,y) | built-in default | |prefix + q| detach | built-in default | |prefix + ?| keybinds help overlay — lists every active binding with labels (herdr's native which-key; manually invoked, not an auto-timeout hint) | built-in default | |prefix + ,| rename tab | rebound (tmux muscle memory) | |prefix + shift + r| reload config (prefix + rstays resize mode) | rebound | |prefix + Alt + e| edit only the active runtime config, validate it, and reload (never invokes chezmoi) | command pane | |prefix + shift + b| new git worktree (moved offprefix + shift + g) | rebound | |prefix + d|devrepository/task/worktree dashboard | command pane | |prefix + G/Alt + g| lazygit temporary pane (a near-full popup was rejected because it blocks tab/workspace switching until closed) | command pane | |prefix + Alt + p/Alt + i/Alt + n| gh-dash temporary pane on the PRs / issues / notifications view, scoped to the focused pane's repo (ttoggles) | command pane | |prefix + S| Sidecar to the right of the focused pane, 50/50, in its current directory; focus moves to Sidecar | command pane | |prefix + Alt + c/Alt + x| claude / codex + SpecStory, split right (details) | detached command | |prefix + Alt + t| claude + SpecStory in a new tab | detached command | |prefix + Alt + r| repeat the last agent launch (from a hotkey or the Agent: Quick Actions) | detached command | |prefix + Y| Yazi file manager rooted at the focused pane cwd;qreturns to the unchanged layout | 90% × 85% popup | |prefix + M| btop system monitor | command pane | |prefix + N| nvtop GPU monitor | command pane | |prefix + U|tv tools(CLI launcher) | command pane | |prefix + u| URL picker — fzf-pick a URL from the pane and open it (x open); tmux-fzf-url analog.--source recent= full scrollback | command pane | |prefix + T|tv herdr-sesh(workspace/dir switcher) | command pane | |prefix + a|tv herdr-panes(non-shell / agent / process / shell panes) | command pane | |prefix + Alt + A|tv agent-wakeup(dual-backend quota waits + scheduled continue) | command pane | |prefix + f|tv fleet-hosts(SSH picker) | command pane | |prefix + Alt + F| prompt for pane-content pattern →herdr-grep --pick --visible→ fzf exact jump; Alt+S = scrollback, Alt+V = visible | command pane | |prefix + m| toggle review-pending flag (⭐) on the current pane | command pane | |prefix + i|tv herdr-review— review-pending inbox (flagged panes) | command pane | |prefix + p| copy a file path from the pane — two-tier fzf (paths that exist under the pane cwd on top), copies the resolved absolute path (x copy) | command pane | |prefix + `| scratch shell | command popup | |prefix + E| run any command in the pane's cwd — fzf-pick from history or type it; the popup closes itself when the command exits (details) | command popup | |prefix + O| herdr-plus Projects (layout launcher) | plugin action | |prefix + t| interactive translator — enter or paste text into thetranslateTUI (details) | 80% × 80% popup | |prefix + y| herdr-plus Quick Actions | plugin action |Uppercase letters resolve to
prefix+shift+<letter>; many are built-ins (shift+gworktree,shift+trename-tab,shift+h/j/k/lswap-pane).prefix+G/prefix+Tare freed by the rebinds above, whileprefix+Yis unused upstream;herdr server reload-configreports any remaining collisions in itsdiagnostics.
The direct lazygit shortcut uses lowercase alt+g (no prefix); alt+G would require Alt+Shift+g. Ghostty/cmux sends the configured Meta key from Left Option (macos-option-as-alt = left).
prefix+S (Ctrl+b, then Shift+s) uses Herdr's native temporary-pane lifecycle
and ~/.config/herdr/sidecar.sh. The helper removes the command pane's initial
zoom to expose a 50/50 split beside the source pane; focus stays on Sidecar.
Exiting Sidecar closes the temporary pane and restores the previous layout.
Repeated presses open additional panes. Sidecar and td are installed together
under installCodingAgents; the launcher reports a missing Sidecar rather than
installing it or initializing a task database. This keeps the agent in Herdr's
original pane; see Sidecar's companion and integrated workflows
for how that differs from starting agents inside Sidecar Workspaces. See the
Windows/WSL compatibility audit before
trying this workflow in the separate Windows dotfiles repo.
Sidecar 0.71.1 requires 80 columns × 24 rows inside its pane; 1.14.0
requires 60×24. On a phone, keep this binding and press prefix+z to zoom
Sidecar; press it again to restore the split. If even the full pane is too small,
reduce font size or change orientation. Herdr's mobile layout does not remove
tiled splits. See Sidecar's size guard and upgrade caveat.
prefix+Y is deliberately session-modal because it is for quick filesystem
operations while reading an agent pane. It starts in the focused pane's cwd,
but its final directory cannot change the already-running source shell; use the
shell y helper when exit-to-directory behavior is wanted.
Session navigator (prefix + g) — in-popup keys¶
The navigator is herdr's Cmd-K analog: a space → tab → pane tree with fuzzy search and agent-state filters. Its footer only advertises enter switch · / search · b/w/i/d/a states · j/k/↑↓ move · esc close, but several keys are live and undocumented upstream (verified against herdr 0.7.5 src/app/input/modal.rs):
| Key | Effect |
|---|---|
space |
toggle expand/collapse of the highlighted space — fires only on a space row; silently no-ops on tab/pane rows |
ctrl + d / ctrl + u |
half-page down / up |
home / end (or G) |
first / last row |
backspace |
drop an active state filter (b/w/i/d) back to all |
a |
clear the query and the state filter |
esc |
closes the popup immediately (on ≤ 0.7.2 it cleared query + filter first and only closed on a second press) |
Three limits that stop space from acting as a collapse-all (src/app/actions.rs):
- Every open resets to fully expanded —
open_navigator_from()clearsexpanded_workspacesthen re-inserts every workspace. Collapse state does not survive closing the popup. - Any search query or
b/w/i/dfilter force-expands everything (expanded = query_kind != Empty || …). Collapsing only has a visible effect undera/ empty query. - The navigator's expand set is separate from the sidebar's
collapsed_space_keys— the latter is what~/.config/herdr/session.jsonpersists, driven by the sidebar's right-clickExpand/Close groupmenu. Collapsing a space in one does not affect the other.
There is no expand-all / collapse-all and no depth cap. Upstream asks for both were closed unactioned under the issue-template policy (issues are bug-only): #1256 (ui.goto_depth = "workspace" | "tab" | "pane") and #1255 (vim h/l to collapse/expand) → discussion #1248.
For a spaces-only overview, reach for
prefix + T(tv herdr-sesh) instead. It is a flat workspace list by construction — a permanent collapse-all view — and it can additionally create a space from a zoxide frecency dir, which the native navigator cannot.prefix + wis the other space-level option (native sidebar navigate mode) but has no fuzzy search.
Session helpers: hvibe / hcode / hhere / hroot (herdr analogs of svibe / scode / shere / sroot)¶
Four shell functions in dot_config/shell/24_herdr.sh are the herdr counterparts of the tmux svibe / scode / shere / sroot helpers. The two heavyweight ones (hvibe / hcode) spin up a whole coding workspace in one motion; the two lightweight ones (hhere / hroot) just open a plain workspace at a directory and attach — no git repo, no agent layout. They shell out to the native herdr workspace|tab|pane CLI and reuse the pure logic verbatim (specstory wrapping, --on-exit shell|kill|restart, git-root resolution, agent-CLI detection) from 22_sesh.sh; only the layout calls differ. Need the herdr server running + jq.
| Command | Builds workspace | Layout |
|---|---|---|
hvibe [N] [CLI] / hvibe --agents claude,codex,opencode |
vibe/<repo> |
tab agents (N even-width agent panes) + tab git (lazygit) + tab edit (nvim) |
hvibe --tab-per-agent … |
vibe/<repo> |
one tab per agent + git + edit tabs |
hcode [CLI] |
coding-agent/<repo> |
tab editor (nvim 75% | agent 25%) + tab monitor (btop) |
hhere [CMD...] |
<basename $PWD> |
single tab, plain shell at $PWD (or -p DIR); optional CMD in the root pane |
hroot [CMD...] |
<basename git-root> |
same as hhere but at the current git root (falls back to $PWD) |
- The plain-open pair (
hhere/hroot) fills the gap where every other herdr entry point forces a git repo + a full agent layout. With tmux,tmux new-sessionlands you in$PWDdirectly; herdr adds a Workspace layer, so without these you'd launch herdr, create a space (which opens at$HOMEpernew_cwd), thencdby hand.hheredoes the one-shot:herdr workspace create --cwd "$PWD"→ focus → attach if outside. No git requirement; the optional command runs raw (no specstory/on-exit wrapping — that stays withhcode/hvibe).hhere -h/hroot -hfor flags. Label caveat: herdr auto-relabels a workspace to its root pane's live cwd basename after acdin tab 1 (see the cwd & workspace-naming model section above), so the idempotent-focus is best-effort — a drifted label makes a re-run open a fresh workspace.-p DIRis absolutized in your shell (via_herdr_abs_dir, shared withhcode/hvibe) before it reaches herdr, so relative paths likehhere -p ../siblingwork and a typo'd DIR is a hard error instead of a silent workspace at$HOME— see the--cwdbullet in the cwd model section for why that guard exists. - Idempotent per repo: re-running focuses the existing
vibe/<repo>/coding-agent/<repo>workspace instead of duplicating (matches svibe's "attach if exists"). - Attach-aware (like svibe's
$TMUXbranch): run from inside herdr → the workspace/tab focus calls switch the live client (no new client). Run from a plain terminal outside herdr → the helper brings up a client attached to the session so the new workspace is actually visible (herdr'sworkspace focusonly moves an already-attached client, so without this the pack was built invisibly).--no-attachbuilds detached either way. Detection is the ambientHERDR_ENV. --session NAMEtargets a specific running herdr session (see Named sessions). Default: the current session when inside herdr (via inheritedHERDR_SOCKET_PATH), else the default session. The override is scoped withlocal -x HERDR_SOCKET_PATHso it never leaks into your shell. A--sessionthat isn't running errors with aherdr --session NAMEhint (start it first — hvibe won't spawn a server).- Agent visibility: herdr tracks agent state per pane, so in the default splits layout every agent still surfaces individually in herdr's agent tracking (verified: two agents in one tab appear as two separate entries in
herdr agent list). The compact left sidebar rolls a tab's status into one dot — use--tab-per-agentif you want each agent to own a tab-level status dot. - Even columns: herdr has no
select-layout even-horizontal, sohvibesets each split's--ratioexplicitly (1/(N-m+1)) to keep the agent panes even;hcodeuses--ratio 0.75to give nvim 75%. - Same flags as svibe/scode:
--on-exit,--no-specstory,--no-attach,-p/--path, plus--session NAME;hvibealso takes--min-width/--tab-per-agentand honors$HVIBE_MIN_WIDTH/$HVIBE_LAUNCH_STAGGER. Full flag help:hvibe -h/hcode -h.
The tmux svibe / scode remain the tmux-side equivalents (see sesh); the two families coexist — hvibe/hcode only touch herdr, svibe/scode only touch tmux.
herdr-plus plugin (sesh + tmuxp + menu analog)¶
herdr-plus adds Projects (declarative multi-tab/multi-pane workspace templates with a fuzzy picker — the tmuxp/tmuxinator analog) and Quick Actions (a fuzzy command launcher — the popup-menu analog).
Install is automated by the devtools ansible role (the # --- herdr-plus plugin --- block) — idempotent, runs on every chezmoi apply, skipped once installed. It shells out to:
herdr plugin install downloads a prebuilt release binary when no Go toolchain is present, so it works with or without Go. The one trap: if a stale Go is on PATH (e.g. an old /usr/local/go shadowing a newer one) herdr tries to build from source and fails (invalid go version … must match format 1.23) instead of falling back. The ansible task sidesteps this by prepending mise's Go (mise which go → its bin dir) when available; to fix it by hand, put a modern Go first: PATH="$(dirname "$(mise which go)"):$PATH" herdr plugin install cloudmanic/herdr-plus. (Go is mise-managed now, gated on installExtraRuntimes.)
Project templates are chezmoi-managed under dot_config/herdr/plugins/config/cloudmanic.herdr-plus/projects/ → the same path under ~/.config/. The shipped chezmoi.toml mirrors this repo's tmuxinator chezmoi session (editor/git/shell tabs). Bind prefix+O → Projects and prefix+y → Quick Actions (already in the config).
Quick Actions are chezmoi-managed alongside them, under …/cloudmanic.herdr-plus/quick-actions/ (one TOML per action). The repo ships the six copy actions (see Copy pane + space facts) plus the plugin's five starter examples (GitHub / Google / Search Google / Open Repo / Reveal Working Dir), adapted for Linux — macOS open → the repo's cross-platform x open, and the repo-select points at daviddwlee84/*. The copy operations deliberately live here instead of consuming six direct prefix keys. Because this managed dir is non-empty, herdr-plus does not auto-seed those examples itself (it only seeds into an empty dir), which is why they're vendored here; delete any TOML you don't want. Add your own by dropping a TOML here, or ship a repo-local set in <repo>/.herdr-plus/quick-actions/.
Quick Actions are for one-off, non-interactive commands (the shipped examples all
open <url>). herdr-plus runs a chosen action viash -cwith no PTY and no interactive stdin, so an interactive TUI misbehaves — btop exits immediately, nvtop can't receive F10. For a TUI you want the dev/lazygit-style floating command pane ([[keys.command]] type="pane") instead — that's why dev (prefix+d), btop (prefix+M), and nvtop (prefix+N) are keybinds, not Quick Actions.
Open a coding agent (prefix + y → Agent: …, prefix + Alt + c/x/t/r)¶
Open claude / codex / opencode / claude-copilot-once / codex-copilot-once beside the focused pane without typing into a fresh shell.
- Menu —
prefix + y, typeagent: one Agent: … Quick Action per agent (level 1), then a select grouped byheadingrows — SpecStory (record) / Direct × → Split right / ↓ Split down / ⊞ New tab (level 2). herdr-plus has no deeper nesting (anOptionis onlylabel/value/description/heading), so the agent is the first level and recording × placement is the second. OpenCode shows only Direct (no SpecStory provider); the copilot variants map to--specstory-mode run|off. - Hotkeys —
prefix + Alt + cclaude+SpecStory → right,Alt + xcodex+SpecStory → right,Alt + tclaude+SpecStory → new tab,Alt + rrepeat the last choice (~/.cache/herdr/agent-open.last).
Why it is faster than a manual split. herdr can only spawn a pane with the default shell. ~/.config/herdr/agent-open.sh creates the split/tab with --env HERDR_AGENT_LAUNCH="<agent> <run|off>"; a guard at the very top of ~/.zshrc / ~/.bashrc (before oh-my-zsh / ble.sh) runs ~/.config/herdr/agent-run.sh as a child with a lean env, then lets the rc continue — so when the agent exits the pane becomes a normal, fully loaded shell. Measured: agent detected ~1s after the keypress.
The lean env = 00_exports.sh (PATH) + 09_editor.sh + the cwd's mise env + ~/.shellrc.secrets + ~/.config/bash/secrets.sh, plus 43_copilot_proxy.sh for the copilot variants. Not loaded: ~/.config/zsh/secrets.zsh and every interactive module. If an agent needs the full interactive env, set HERDR_AGENT_FULL_RC=1 for the launcher (plain split, then herdr pane run into the loaded shell — the slow path). Agents and modes are allowlisted in both scripts; nothing from the env var is eval'd. Tests: tests/unit/herdr_agent_open.bats.
Television integration¶
Most tv channels (tools, fleet-hosts, mlflow, kill-process, ssh-config) have non-tmux-coupled actions, so they run unchanged in a herdr command pane — just bind a key to tv <channel>. Only the channels whose actions call tmux … need herdr-aware variants. Three ship here:
herdr-sesh(dot_config/television/cable/herdr-sesh.toml) — lists herdr sessions/workspaces + zoxide dirs; Enter dispatchesherdr session attach/herdr workspace focus/herdr workspace create --cwdinstead ofsesh connect/tmux switch-client.Ctrl+Dcloses the selected workspace;Alt+Ycopies its directory (works on workspace and zoxide-dir rows — seedirvscwd). The preview shows that derived dir above the rawworkspace getJSON, so you can see whatAlt+Ywill hand you.herdr-panes(dot_config/television/cable/herdr-panes.toml) — general pane picker, renamed fromherdr-agent-panes.Ctrl+Scycles non-shell → agent → process → shell;Ctrl+Rrescans the current session. Rows show type, workspace/tab/pane, foreground programs, agent state, and title or cwd. Enter / Alt+T exits the picker and jumps to the exact pane, including split panes, through the sharedfocus-pane.pyhelper. Alt+K confirms before closing one pane and exits; Ctrl+Y / Alt+P copy its ID / current directory. See TV pane picker.herdr-review(dot_config/television/cable/herdr-review.toml) — the review-pending inbox: lists only panes carrying the ⭐ flag (a non-emptytokens.review). Enter focuses the pane's workspace/tab and keeps the flag;Alt+Cfocuses and clears it ("mark read"). Bound toprefix+i. See the Review-pending flag section below.
The original tmux-bound sesh / agent-panes channels are left intact for coexistence.
mode = "execute", not"fork", for the Enter action of a channel bound to a command pane. Atype = "pane"binding only vanishes when its command exits, and tv exits after anexecuteaction but stays alive after aforkone — so aforkEnter leaves the picker pane hovering over the workspace you just focused.herdr-sesh's[actions.open]isexecutefor exactly this reason; itsctrl-d(close_ws) keepsforkbecause it pairs withreload_sourceand tv must survive to redraw the list.herdr-panesalso usesexecutefor Enter / Alt+T and confirmed close.herdr-reviewretains its existingforkfocus behavior for triage.
Agent state (replaces the 6-file workmux integration)¶
herdr detects agent state natively and rolls it up into the sidebar (a blocked agent marks its pane/tab/workspace). Claude Code is detected via screen-manifest heuristics (terminal title + OSC progress), not lifecycle hooks. If the heuristics prove insufficient, state can be pushed explicitly:
For the trial we rely on native detection — the tmux-side workmux 🤖/💬/✅ system (Claude/OpenCode hooks → @workmux_status) is untouched and only applies under tmux.
The managed UI uses fixed Catppuccin Mocha and status_indicators = "symbols". Agent rows show state/workspace/tab/review, then machine · agent, then the task's terminal_title_stripped. Machine labels disappear with only Local; absent titles omit the third row. Alt+g and prefix+G both launch LazyGit in a temporary pane.
Optional agent integrations (the onboarding "install" button)¶
herdr's first-run onboarding offers to install optional agent integrations (herdr integration install <agent>), so agents report state directly instead of relying on screen heuristics. Pressing install sets these up for every detected agent. What it writes (verified on this machine):
The Herdr release and integration schema have separate version numbers. On
this machine herdr --version is 0.8.0, while that release ships Codex
integration v7; outdated (v6 < v7) refers to the integration payload,
not an older Herdr binary.
| Agent | What herdr integration install creates |
Touches a repo-managed file? |
|---|---|---|
| claude | ~/.claude/hooks/herdr-agent-state.sh + a hook entry in ~/.claude/settings.json |
Yes — but the repo's hook-aware modify_settings.json.tmpl merger preserves it (same as it does for CodeIsland). chezmoi apply is a no-op; it won't strip the herdr hook. |
| codex | ~/.codex/herdr-agent-state.sh + a SessionStart entry in ~/.codex/hooks.json |
Partly — the script remains Herdr-owned; hooks.json is a hook-aware chezmoi modify_ target that preserves Herdr while keeping all user-layer hooks in one JSON representation. Herdr v7 was verified to merge and remain idempotent. |
| opencode | ~/.config/opencode/plugins/herdr-agent-state.js (separate plugin) |
No — only workmux-status.ts is managed; herdr's plugin coexists. |
| cursor | ~/.cursor/herdr-agent-state.sh + hook |
Script lives outside chezmoi; coexists. |
The integration scripts are not vendored into the repo, so they do not reproduce on other machines (press install again there, or skip onboarding). Hook-aware modify_ targets preserve their registrations. They use herdr's own socket and do not interfere with tmux/workmux (different mechanism). To remove: herdr integration uninstall <agent>.
Config management (why modify_)¶
Herdr is a foreign writer for ~/.config/herdr/config.toml. Upstream v0.8.2 and current master persist onboarding, theme/UI, sound, toast, pane-label and agent-sort settings, the update channel, and key resets. Those edits preserve unrelated lines and unknown sections, but finish with an unlocked whole-file write; external edits still need an explicit reload.
The file was originally seeded once with create_. During the 2026-07 transition, the checked source and live target were measured byte-identical: there was no observed onboarding-clobber incident. The move to modify_ was a preventive dual-writer correction made while fixing split-key and cwd behavior. create_ avoided overwriting Herdr's writes, but also meant later repo edits would never reach an already-seeded host.
It is now a modify_ overlay — dot_config/herdr/modify_config.toml.tmpl, a small script that:
- uses
.chezmoitemplates/herdr/config.tomlas the managed base and enforces[theme],[ui],[terminal], and[keys]wholesale on every apply; and - pulls through every other live top-level key.
onboardingandupdate.channelare genuine current Herdr writes; other[update]subkeys,[session],[remote],[experimental], unknown sections, and similar values are deliberately preserved as live/user-owned configuration, not mislabeled as current writeback.
TOML has no jq, so the merge runs in Python via uv run --with tomlkit (tomlkit round-trips comments and the [[keys.command]] array-of-tables; stdlib tomllib is read-only and the codex modify_ emitter can't emit AoT). It degrades to system python3, then to emitting the raw managed template, so a fresh host without Python still gets a full config. Second TOML-overlay precedent alongside ~/.codex/config.toml (dot_codex/modify_config.toml.tmpl).
A later chezmoi apply intentionally reasserts the canonical [theme], [ui], [terminal], and [keys] tables. Persisting one runtime experiment is therefore a separate, manual, selective edit of .chezmoitemplates/herdr/config.toml. Never run chezmoi add or chezmoi re-add for this modify_ target: either can replace/bypass the merger or import runtime-owned state into source.
Edit runtime config, validate, and reload (prefix + Alt + e)¶
prefix + Alt + e opens a temporary command pane running ~/.config/herdr/edit-config.sh. It edits only the active runtime target: a non-empty $HERDR_CONFIG_PATH, otherwise ~/.config/herdr/config.toml. It never invokes chezmoi. Lowercase e is distinct from the prefix + E command popup and Herdr's built-in prefix + e scrollback editor.
The guarded operation is ordered:
- Require an existing regular target (Unix symlinks are rejected), resolve one blocking
$EDITORexecutable/wrapper (vifallback), and create a unique same-directorycp -pbackup before opening the editor. - Edit the exact target, then validate that same path with
HERDR_CONFIG_PATH="$target" herdr config check. - On editor or validation failure, save the rejected bytes as a restrictive
config.toml.invalid-*sibling and atomically restore the previous valid backup with a same-filesystem move. No reload occurs. - After successful validation, call
herdr server reload-config; inherited$HERDR_SOCKET_PATHkeeps the reload on the current session. Success removes the backup.
Preflight or backup failure leaves the target untouched and never opens the editor. A reload failure does not discard a valid edit: both the edited target and original backup remain. A cleanup-only failure likewise keeps/reports the backup without rollback. Rollback failure reports and retains every recoverable target/candidate/backup path. Failures remain visible in an interactive command pane until Enter; non-interactive runs do not wait.
Persistence & restore (accidental close)¶
Two native layers, no plugin needed:
- Client/terminal close ≠ session loss. herdr runs a persistent server (named session
default, socket~/.config/herdr/herdr.sock). Closing the terminal window just detaches — workspaces/tabs/panes and their running processes stay alive. Reattach with bareherdr(orherdr session attach default);herdr session listshows itrunning. This is tmux detach/reattach, built in. - Full server restart / reboot / crash.
[session]in the config exposesresume_agents_on_restore = true— resumes supported AI-agent panes back into their native conversation sessions after a server restart (requires the officialherdr integration install <agent>hooks) — plus an option to save recent pane screen history across restarts. This covers the tmux-resurrect/continuum case natively, gated on those keys + integrations.
Remote sessions (herdr --remote)¶
herdr can run a local thin client attached to a herdr server on another host over SSH:
herdr --remote <ssh-target> [--session NAME] [--handoff]
herdr --remote local_ubuntu # <ssh-target> is any host/alias from ~/.ssh/config
What it does (from https://herdr.dev/docs/remote):
- SSHes to
<ssh-target>, detects the remote platform, and auto-installs a matching herdr server binary there — the remote does not need herdr pre-installed (it does need SSH access + permission to install/run a binary). - Bridges your local clipboard (including image paste) to the remote server and keeps your local keybindings (
--remote-keybindings serverto use the server's instead). --session NAMEpicks/creates a named session on the remote;--handoffopts into live handoff.
Config — the [remote] block in config.toml:
manage_ssh_config = true(default) — herdr runs the bridge SSH through a generated config that includes your~/.ssh/configfirst and addsServerAliveInterval/ServerAliveCountMaxso idle NAT/network timeouts don't drop the session. Setfalseto run plain SSH against your config unchanged.remote_image_paste([keys]) — raw-key image-paste binding, active only under--remote.
Troubleshooting remote platform detection failed: Connection closed by <host> port 22: this is a transient SSH-level close, not a herdr config problem. Verify the SSH path herdr uses actually works, then just retry:
ssh -o BatchMode=yes -o ConnectTimeout=5 <ssh-target> 'uname -sm' # should print e.g. "Linux x86_64"
If plain SSH works, retry herdr --remote <ssh-target>; if it keeps failing, check ~/.config/herdr/herdr-client.log / herdr.log right after the failure. Common real causes: SSH auth needing an interactive prompt the bridge can't answer, or sshd rate-limiting rapid connections (MaxStartups).
Running herdr nested inside tmux (multi-remote)¶
The repo's default stance is herdr or tmux, not nested. But you can deliberately nest — tmux as the outer window manager with several inner herdr clients, some local and some herdr --remote <host> to different servers. The catch is the prefix collision: both default to Ctrl+b, so outer tmux swallows it and inner herdr never sees its prefix (herdr #759).
Three ways to resolve it, in order of preference here:
- Double-prefix passthrough (recommended, zero config). tmux keeps its default
bind -T prefix C-b send-prefix(confirmed present in this repo — not overridden), soCtrl+b Ctrl+bforwards a literalCtrl+bto the inner herdr; its prefix bindings then work normally (Ctrl+b Ctrl+b c= inner new tab). No herdr or tmux change needed — classic tmux-in-tmux muscle memory. - Rebind the inner herdr prefix. Set
keys.prefix(e.g."ctrl+a"/"ctrl+space") so the inner herdr uses a non-colliding prefix — one keystroke instead of the double-tap. Cost: it changes herdr's prefix even when run standalone (no longer matching tmux'sCtrl+b). - Don't nest — use herdr's native remoting.
herdr --remote <target> [--remote-keybindings local|server]runs a local thin client against a remote herdr server without tmux in the middle, and herdr hosts multiple named sessions itself. For a pure "multiple remotes" need this is often cleaner than nesting;--remote-keybindings local(default) keeps your local keymap,serveruses the remote's.
Notes: this repo's tmux uses many root-table bind -n C-* bindings that shadow inner-app Ctrl keys, so herdr's prefix-free direct terminal shortcuts aren't reliable under tmux — reach herdr actions through its prefix (via 1 or 2). herdr's own allow_nested (config, default false) governs herdr-inside-herdr only (detected via HERDR_ENV), not herdr-inside-tmux.
Review-pending flag (mark-unread / ⭐)¶
The tmux setup has a per-window bookmark (@bookmark_status + toggle-bookmark.sh, rendered via #{?@bookmark_status,…}). herdr has no #{@option} status-bar interpolation, so that exact mechanism doesn't port — but the use case that actually matters here does: "the agent finished (done) but I haven't reviewed it yet." herdr collapses a done pane to idle the moment you peek in, losing the only signal that it still needs attention.
The fix uses herdr's per-pane metadata tokens, which are orthogonal to native agent detection:
herdr pane report-metadata <pane> --source review --token review="⭐ REVIEW" # set (persistent — no --ttl-ms)
herdr pane report-metadata <pane> --source review --clear-token review # clear
Verified: a pane can carry tokens.review = "⭐ REVIEW" while agent_status:"idle" — so peeking in does not wipe the flag (the whole point). herdr pane get surfaces the tokens map and herdr pane list (native JSON — no --json flag) lets the inbox enumerate flagged panes, so there is no sidecar file; herdr itself is the source of truth. Presence of the token is the flag, so the glyph text is free-form.
herdr ≥ 0.7.4 only, and the token needs a row layout to be visible. 0.7.4 replaced
--custom-status/ the flatcustom_statusfield with this namespaced token map — a silent removal, not listed as a breaking change. Two consequences: on older herdr the helper dies withunknown --custom-status, and unlikecustom_statusa token is rendered only where a sidebar row layout names it. That is why.chezmoitemplates/herdr/config.tomlpins[ui.sidebar.agents] rowswith"$review"appended to herdr's default first row — drop that and the flag still works throughprefix+i, but the ⭐ disappears from the sidebar. Seepitfalls/herdr-0.7.4-drops-custom-status.md.
Surfaces (all sharing one script, ~/.config/herdr/review-mark.sh = dot_config/herdr/executable_review-mark.sh — the analog of tmux's toggle-bookmark.sh):
| Surface | Action |
|---|---|
hmark / hunmark (aliases in dot_config/shell/24_herdr.sh) |
set / clear on the current pane (ambient $HERDR_PANE_ID), or pass a pane id |
prefix+m |
toggle the flag on the focused pane (uses $HERDR_ACTIVE_PANE_ID) |
tv herdr-review / prefix+i |
the inbox: lists only flagged panes. Enter focuses & keeps the ⭐ (you may still be mid-review); Alt+C focuses and clears it ("mark read") |
Three names must move together: TOKEN in review-mark.sh, the .tokens.review lookups in herdr-review.toml, and "$review" in the sidebar row layout.
Run any command in a popup (prefix + E)¶
prefix + E provides the popup form where it fits: fzf-pick a command from shell history (or type a new one), run it in the focused pane's cwd, and close the popup when it exits. Helper: ~/.config/herdr/run-command.sh = dot_config/herdr/executable_run-command.sh.
Why type = "popup" and not the alternatives — prefix+E deliberately floats instead of opening another command pane:
| Approach | Problem |
|---|---|
type = "pane" (prefix+d/G/M/N) |
splits the tiled layout for the duration; everything reflows |
prefix + c → type → exit |
four steps, and churns the tab bar |
type = "popup" |
session-modal float above the layout — nothing reflows, and you land exactly where you were |
type = "popup" requires herdr ≥ 0.7.4 (added in #1125, with width/height in cells or percentages). It is the true tmux display-popup -E analog.
prefix + `` ` (scratch shell) is a popup too, for the same reason — as a command pane it took over the layout and read as a *zoom* of the current window rather than a scratch space.prefix+d/prefix+G/prefix+M/prefix+N(dev / lazygit / btop / nvtop) staytype = "pane"`. A near-full lazygit popup was tested and removed: popups are session-modal, so leaving one open blocks switching to another tab/workspace. That trade-off is acceptable for short-lived modal tasks, not a full-screen TUI you may leave running.
It cannot be a herdr-plus Quick Action (prefix + y), which is the intuitive place to look. Two blockers: Quick Actions run through sh -c with no PTY/stdin — the same reason btop/nvtop are command panes rather than Quick Actions — and every action is a fixed command = "…" string with no free-text field.
Behaviour:
| cwd | --cwd → $HERDR_ACTIVE_PANE_CWD → herdr pane get foreground_cwd → $PWD. Preferring the env var means it still works when the CLI is protocol-mismatched with a stale server |
| picker | fzf over $HISTFILE (default ~/.zsh_history), newest-first and de-duplicated. Enter runs the highlighted history entry; Alt+Enter runs exactly what you typed, even when history still matches it; typing something with no match at all and pressing Enter also runs it as a new command; Esc runs nothing. Falls back to a plain read prompt when fzf is absent |
| shell | $SHELL -ic by default, so this repo's aliases and functions resolve (gst, cas, x, …). --sh switches to sh -c — fast, but aliases do not exist |
| on exit | closes on success; on failure prints [exit N] and waits for Enter so the error stays readable. HERDR_RUN_HOLD=always\|never overrides |
Two portability traps are handled inside the helper and are worth knowing if you edit it:
~/.zsh_historyis extended-history format (: <ts>:<elapsed>;<cmd>) and contains non-UTF8 bytes, so BSDsedaborts withsed: RE error: illegal byte sequenceunless the parse runs underLC_ALL=C; and the newest-first reversal uses POSIXawkbecausetail -ris BSD-only whiletacis GNU-only.Why
Alt+Enterhad to exist. Plain Enter cannot express "run exactly what I typed" while fzf still has a match — typels -lawithls -la /tmpin history and Enter takes the history entry, which makes the picker feel like it can only replay old commands.--expect=alt-enteradds the escape hatch. Note that fzf's line layout is not fixed: with--print-query --expect, a no-match Enter prints only the query (one line), while an accepted match prints query / key / selection. The helper therefore reads the key from line 2 (empty or absent ⇒ plain Enter) and only trusts line 3 on exit code 0.
Copy pane + space facts to the clipboard¶
Six low-frequency "grab this onto the clipboard" operations live in the prefix+y Quick Actions list, all driven by one helper (~/.config/herdr/pane-copy.sh = dot_config/herdr/executable_pane-copy.sh). It distills a herdr CLI call into human-readable text and pipes it to the repo's own x copy (auto-selects pbcopy / wl-copy / xclip / xsel / OSC 52; x is resolved by absolute-path fallback since a Quick Action may run without the interactive PATH). The action passes the focused pane as $HERDR_PLUS_PANE_ID; the helper falls back to herdr pane current when empty.
| Op | Quick Action | What lands on the clipboard |
|---|---|---|
process |
Copy pane: process info | foreground processes — cmdline + pid + cwd (from herdr pane process-info) |
coord |
Copy pane: coordinate | a paste-ready session / workspace / tab / pane id block + the socket path + a # herdr pane get <pane> line |
content (visible) |
Copy pane: content (visible) | the pane's on-screen text (herdr pane read --source visible) |
content (scrollback) |
Copy pane: content (scrollback) | the pane's scrollback (--source recent --lines 1000), capped at herdr's own per-pane read hard ceiling of 1000 lines — a pane retaining more than that (check .scroll.max_offset_from_bottom from herdr pane get) only yields its most recent 1000 lines; there's no pagination flag to reach further back |
dir |
Copy space: dir | the workspace ("space") root directory — see below |
cwd |
Copy pane: cwd | the focused pane's live working directory (what pwd / abspath returns) |
The coordinate answers "which session > space > tab > pane is this?" in a form you can feed back to the CLI. herdr has no --session flag on the pane/tab/workspace subcommands — a session is targeted only via HERDR_SOCKET_PATH — so the block includes the socket= line as the session selector (the session name is resolved by matching that socket against herdr session list --json).
The copy-*.toml Quick Actions all share the same helper and are fuzzy-launched via prefix+y. Their former direct bindings (prefix+P/D/V/S/d/ctrl+d) were removed to keep the prefix namespace focused on interactive tools; prefix+d now launches the dev dashboard. The interactive file-path picker remains on prefix+p because fzf requires a PTY/stdin and cannot run inside a Quick Action.
dir vs cwd, and why the right-click menu can't do this¶
dir is the answer to "what directory is this space about?" — the thing you'd want from a right-click Copy dir on a sidebar workspace row. Two ways to reach it:
| Surface | How | Scope |
|---|---|---|
prefix+y → Copy space: dir |
herdr-plus Quick Action | the focused workspace |
prefix+T → Alt+Y |
herdr-sesh tv channel |
any workspace in the list — strictly more than right-click could give |
herdr's context menus cannot be extended. They are a fixed enum compiled into the binary — the whole inventory is one packed string blob (Rename · Close · New worktree · Open worktree... · Close group · Expand · Delete worktree checkout... for spaces; New tab · Rename pane · Split right · Split down · Close pane · Swap with focused pane · Clear pane name for panes). Verified on 0.7.5 from three directions: config.toml has no menu table (only [[keys.command]] and ui.right_click_passthrough_modifier, which governs whether right-click reaches the inner app); the plugin manifest's [[actions]] carries contexts = ["workspace"] that looks like menu placement but is inert; and herdr api schema --json contains no occurrence of "menu" at all, so nothing can inject one at runtime either.
Upstream status (re-check on herdr upgrades). Requested four times — #1511 (user-defined menu entries), #1671, #1776, #1830 — all closed
NOT_PLANNED, three of them auto-closed bykangal-botfor being feature requests on a bug-only tracker. #1776 is where a maintainer-facing report states plainly thatcontexts"currently only surfaces in theplugin action listAPI response." The redirected discussions (#1609, #1672, #1722) are all still open and unanswered.
How the space dir is derived — and its one caveat. herdr exposes no workspace-level cwd anywhere: herdr workspace get and herdr api snapshot both return the same label / number / tab_count / pane_count object. So it is computed, by ~/.config/herdr/space-root.sh — the SSOT shared by pane-copy.sh dir, the prefix+C new-tab helper, and the tv channel's preview + Alt+Y:
- Space root = the cwd of the workspace's oldest surviving tab.
.numberon a tab is a monotonic creation counter, not the display index — a space can hold tabs numbered10/13/14/15shown as1/2/3/4— sosort_by(.number)[0]means "oldest", which is the right notion of "the tab this space started as". - Caveat: the sidebar label is pinned at creation and never re-derived, so once you
cdinside that oldest tab the label and the derived dir drift apart, and nothing in the API can recover the original path. Observed live: a space labelled2026-05-14-grafana-provisioning-with-docker-otel-lgtmwhose oldest tab sits in…/grafana/dashboards/Jingle.AI.dirreports the latter — the true current directory, not the stale name.
cwd exists because the two genuinely diverge in normal use: a space rooted at 2026-07-24-unify-ashare-sdk was observed with panes sitting in three unrelated Documents/Program/* trees. dir is the project; cwd is where this pane actually is.
Open a URL from the pane (prefix+u)¶
The herdr analog of tmux's prefix + u (joshmedeski/tmux-fzf-url). prefix+u opens an fzf popup listing every URL in the focused pane; pick one (or several — fzf multi-select) and each opens in the browser. Lowercase u is deliberately paired with uppercase U (tv tools), matching the tmux muscle memory.
One helper drives it: ~/.config/herdr/url-pick.sh = dot_config/herdr/executable_url-pick.sh. It reads the pane (herdr pane read), runs the same extraction/rewrite passes as tmux-fzf-url, and opens each choice with the repo's cross-platform x open (wslview / open / xdg-open). It mirrors pane-copy.sh exactly: pane defaults to $HERDR_ACTIVE_PANE_ID (the keybind var) with a herdr pane current fallback, and x is resolved by absolute-path fallback since a command-pane may run without the interactive PATH.
| Aspect | Behavior |
|---|---|
| Scope | Visible screen by default (matches tmux-fzf-url). url-pick.sh <pane> --source recent scans the full retained scrollback |
| Patterns | http(s) / ftp / file, bare www. → http://, IPv4[:port] → http://, git@… SSH remotes → https://…, quoted "owner/repo" → github.com/owner/repo, import "pkg" → npmjs.com/package/pkg |
| Open | x open <url> per selection; multi-select opens all |
| No URLs | prints no URLs found and pauses ~1.5 s (a command pane closes the instant the script exits — tmux uses the status line instead) |
Bound via a [[keys.command]] type="pane" in .chezmoitemplates/herdr/config.toml — no tv channel needed (fzf is the faithful port). Copy-mode URL opening (tmux's tmux-open o) has no herdr equivalent; use prefix+u for the picker.
Copy a file path from the pane (prefix+p)¶
The copy-path sibling of the URL picker. prefix+p opens an fzf popup of the file paths in the focused pane; pick one (or several) and the path is copied to the clipboard. It keeps a direct key because the interactive fzf cannot run inside prefix+y Quick Actions. On tmux the analog is the extrakto plugin (prefix + Tab) — see tmux keybindings.
Helper: ~/.config/herdr/path-pick.sh = dot_config/herdr/executable_path-pick.sh, a direct sibling of url-pick.sh/pane-copy.sh (same pane + x-resolution + herdr pane read plumbing), copying via x copy.
The noise problem, and how it's handled. Unlike URLs (which self-identify with scheme://), file paths have no marker, so a regex alone matches dates (2024/01/02), rates (10k/s) and fractions (1/2). Two defenses:
| Defense | Behavior |
|---|---|
| Extraction | extrakto's path heuristics — slash-bearing tokens (/…, ~/…, ./, a/b/c) + bare file.ext; trailing ",): stripped; rate/fraction tokens excluded outright |
| Existence check (the noise-killer) | Each candidate is resolved against the pane cwd ($HERDR_ACTIVE_PANE_CWD, else herdr pane get foreground_cwd, else process-info cwd) and test -e'd |
| Two-tier list | Paths that exist show first (copied as their resolved absolute path); everything else — remote / stale / hypothetical — appears below a ── unverified ── separator (still selectable), so real-but-unresolvable paths aren't lost |
path:line:col suffixes (grep -n / stack traces / compiler output) are stripped before the existence test, so pkg.py:42:5 validates as pkg.py. Scope is the visible screen by default (--source recent for scrollback); multi-select copies newline-joined; an empty result prints no file paths found and pauses ~1.5 s.
Translation: interactive TUI and pane capture¶
prefix+t opens the translate interactive TUI in an 80% × 80% popup. Enter or paste the text to translate; this shortcut does not capture the focused pane.
For pane translation, use prefix+y → Translate pane. The helper pipes the focused pane's content through translate in bilingual mode and shows the result in a new pane: the original lines stay verbatim, each block's translation interleaved beneath as ↳ …. There is no tmux analog — this is herdr-only, built on herdr pane read.
Helper: ~/.config/herdr/pane-translate.sh = dot_config/herdr/executable_pane-translate.sh, a sibling of pane-copy.sh / url-pick.sh / path-pick.sh (same pane resolution, same herdr pane read plumbing).
| Entry point | Scope | Where the result lands |
|---|---|---|
prefix + t |
text you enter or paste | interactive TUI in a popup |
prefix + y → Translate pane |
select list: current page / last 200 / 500 / 1000 lines | a pane split to the right, focused |
prefix + y → Translate pane into… |
current page | same, with a target language you type |
prefix + y → Translate pane: copy |
current page | the clipboard (x copy) + a notification |
How much scrollback — and why "the current page" is the honest default¶
The obvious worry is that a fixed window cuts mid-sentence, and that a coding-agent plan can run past one screen. Three facts decide it:
- An agent pane on the alternate screen has no scrollback at all. A Claude Code pane reports
scroll.max_offset_from_bottom: 0, andherdr pane read --source recent --lines 1000returns exactlyviewport_rows— byte-identical to--source visible. Rows that leave the alternate screen never enter herdr's host scrollback, so no--linesvalue recovers them. (A shell or acodexpane in the next tab happily reports thousands of rows: this is per-application, not global.) So for the case that motivates the feature, one page is all there is — and because--source visiblerenders whatever you scrolled to inside the app, "the current page" is exact rather than a guess. Scroll in the agent, then invoke the pane-translation Quick Action again. herdr pane readcaps at 1000 lines and has no offset or pagination flag, sorecent:1000is a hard ceiling — the same limitpane-copy.shdocuments.- A 1000-line read is ~62 KB.
translatecosts roughly 10 KB ≈ 60 s and 20 KB ≈ 95 s, and providers reject well before 62 KB. The real cap is therefore a character budget (HERDR_TRANSLATE_MAX_CHARS, default12000≈ 75 s), not a line count.recent:Nonly picks a coarse window that the budget then trims — and every trim is reported in the pane header, never silent.
Not cutting mid-content¶
Handled in two places, and the second one matters more:
| Mechanism | What it does |
|---|---|
Top-edge boundary snap (recent:N only) |
Scans the first clamp(15%, 5, 40) lines for a block boundary — a blank line, an agent-turn marker (●⏺⎿•✻>❯$#), a horizontal rule, or a heading-shaped line — and starts there, prepending [… earlier output omitted …]. If no boundary is found in the margin it keeps everything and prepends [… continued from earlier output …]. A visible capture is never trimmed at the top: it is exactly what you are looking at, so it only ever gets a marker. |
--instructions |
The capture is sent with a system-prompt line telling the model it is a terminal excerpt that may begin or end mid-sentence, that it must translate exactly what is present without completing it, and that commands / paths / flags / identifiers / code / JSON stay verbatim. An LLM translator does not need clean boundaries; it needs to be told they are dirty. |
recent:N reads --source recent-unwrapped, which joins soft wraps, so long lines never reach the translator broken mid-word.
Cleanup before translation¶
Three passes, all bounded so content is never eaten:
- Bottom chrome only. The walk starts at the last row and stops at the first real content row: input frames (
╭…╰), footer hints (? for shortcuts,⏵⏵ bypass permissions,-- INSERT --, a bare❯), status bars (several·/│-separated fields) and spinner rows. A custom status block is cut by its anchor instead: a full-width rule near the bottom with recognisable chrome under it. A rule printed mid-transcript is left alone. - Modal dedent. Load-bearing:
translate's bitext classifies a block as code once its indent is ≥ base + 2, and an agent pane renders prose behind a uniform left margin. The base is the modal indent, not the minimum — a transcript mixes turn markers at column 0 with prose at column 5, somin()would dedent nothing and the whole page would come back untranslated. The modal indent is the pane's dominant text column, so prose lands at 0 while a genuinely nested code block keeps its relative indent and stays classified as code. - Collapsed-transcript markers (
… +194 lines (ctrl + t to view transcript)) become a bare[…]; blank runs collapse; tabs expand.
Check any of this without spending an LLM call:
It prints the cleaned, repaired capture on stdout and raw_lines=… out_chars=… trimmed=… on stderr.
Interactive popup and pane-translation Quick Actions¶
Same split as prefix + E: Quick Actions run through sh -c with no PTY/stdin, so they cannot host the pager themselves — they capture first, then herdr pane split a real pane and re-exec the helper inside it (pane split has no --focus flag, so the new pane is focused via the shared focus-pane.py). The direct prefix+t popup supplies a PTY for the interactive translator. For pane-translation Quick Actions, the capture is always taken before the split so that resizing cannot re-wrap the page being captured.
Lowercase t, because prefix + T is already the herdr-sesh session picker.
Pane-translation helper env (not the direct prefix+t TUI): HERDR_TRANSLATE_MAX_CHARS (12000), HERDR_TRANSLATE_TO (default target language; otherwise translate's own [general] config decides), HERDR_RUN_HOLD (fail|always|never, as in prefix+E), PAGER.
AI usage / quota status¶
herdr has no native usage/quota/token display (the sidebar shows agent state only). It does expose a per-pane hook — herdr pane report-metadata <pane> --source ID --token usage="…" --ttl-ms N — that a driver could push a "Claude 62% • Codex 78%" label into (the same hook the Review-pending flag section above uses, but since herdr 0.7.4 tokens are a namespaced map, so a usage token and the review token coexist instead of contending for one field). A Codex-only community plugin (jerryfane/herdr-codex-usage-kit) already does this from the same ~/.codex data CodexBar reads; nothing covers Claude/ChatGPT quota. Deferred — CodexBar's menu bar stays the multi-provider view. Design + options captured in backlog/herdr-usage-status-driver.md.
Gaps (no clean herdr equivalent)¶
- Seamless
Ctrl-hjklnvim↔pane navigation.vim-tmux-navigatoris tmux-coupled (theis_vimps/pane_ttyheuristic + the nvim plugin). herdr has no smart-splits equivalent — its pane focus isprefix+h/j/k/l, which won't pass through to nvim splits at the edge. Workaround: inside nvim use its own<C-w>hjkl. This is the biggest UX regression vs tmux. - OSC133 copy-mode (
cpout/cpblock, prompt-jump, last-output yank) is tmux-specific. herdr's copy mode (prefix+[) is vi-style but has no OSC133 prompt-boundary awareness.cpcmd(zsh history, multiplexer-agnostic) still works. - Decorative status-bar glyphs (📌/🔖 as free-floating window labels): herdr has no
#{@option}format-string interpolation, so tmux-style status-bar bookmarks don't port. (The specific "mark-unread / review-pending ⭐" use case IS solved — see the Review-pending flag section above — via a per-pane metadata token; only the purely decorative status-bar glyph remains a gap.) - Per-key pane resize. tmux binds
prefix+H/J/K/L(andM-hjklfor fine steps) to resize directly; herdr has no per-key resize — it uses a modalresize_mode(prefix+r), thenh/j/k/l. Not exact parity, but a close analog.
Not a gap: vi copy-mode itself is native (
prefix+[), and per-pane agent state is detected natively — the two things I expected to be missing turned out to be built in.
See also¶
Grouped SSH configuration and dev integration¶
New machines receive a create-only SSH dispatcher with explicit Includes for
config.d/git/github.conf, config.d/git/gitlab.conf and
config.d/common/defaults.conf. Four spaces indent options. Include order,
not directory names or an implicit wildcard sort, defines precedence. The seed
works without dev installed. Existing flat/wildcard configurations retain their
legacy seeds and are never automatically migrated; moved seed files are not
recreated after their exact Include is removed. Grouped seed files are gated
by the dispatcher marker to avoid activating new definitions during an update.
With a dev build containing SSH management, run dev ssh format for the default
indentation preview or explicitly choose dev ssh organize to move complete Host
blocks and their comments into groups. Use --apply after reviewing the plan;
dev ssh restore <receipt> previews recovery. The new dev writes use its
macOS/Linux security backend. dev ssh manage separately selects fleet/Herdr
registrations and native Herdr profile actions; installing dotfiles does not
register hosts or start remote Herdr servers.
The Television SSH channel prefers dev ssh list --format tsv for active aliases
and falls back to a display-only flat/grouped file scan when the machine-management
command is unavailable (including older dev versions).
The shared SSH setup helper follows nested Includes and adds an exact Include for
a newly selected file, so wiring one file does not activate dormant neighbors.
Legacy directory-level helper subcommands remain available for existing callers.
Herdr UI (native GUI client)¶
Herdr UI is a separate client for the existing Herdr daemon. TUI and GUI can share sessions; closing the GUI leaves terminal sessions running. The UI installer never installs or upgrades the Herdr daemon. GUI preferences remain app-owned; apply does not launch the app or enable launch at login.
- macOS: follows
installBrewApps; Homebrew caskpenso/tap/herdr-gpui, Apple Silicon / Intel, macOS 14.2+. Older macOS skips this cask. Open withopen -a Herdr, or use theherdr-gpuiCLI. - Ubuntu/Debian desktop:
profile=ubuntu_desktopandinstallBrewApps=true. Requires glibc 2.39+ (Ubuntu 24.04 / Debian 13 or newer), x86_64 or ARM64, and sudo. Ansible downloads the latest stable.deb, verifies its upstream SHA-256 and installs via apt with runtime dependencies. noRoot, older libc and unsupported architectures skip with a message. Vulkan drivers must be available for the GPU. Launchherdr-gpuior the application-menu entry. - Apply installs missing copies only; existing PATH installations are preserved.
macOS upgrades through Homebrew (
brew upgrade --cask penso/tap/herdr-gpui); Linux uses the app's App Updates panel (PolicyKit authentication agent andpkexecrequired). Manual Linux updates can use the official installer. - Fedora/Arch/Nix installations are manual: see the official installation guide. Headless hosts keep running the daemon; use a GUI client on a desktop over SSH.
just upgrade-herdrremains the daemon upgrade command; it does not upgrade UI.