TermHerd
A terminal workspace for your Claude Code sessions — browse them, launch them, arrange them in tabs and splits, watch which one needs you, and let a session drive the workspace it runs in.
It is a native Rust desktop app (macOS, Windows, Linux — all three first-class), keyboard-driven, built around a real PTY terminal per session.
cargo run -p termherd-app # or install a release build — see Installation
What it is not
Not an IDE. TermHerd deliberately does not emulate an editor and does not
register in ~/.claude/ide: Claude keeps using the editor you already
configured. Dropping IDE emulation is what keeps the product a workspace —
the hardest, largest surface (an in-app diff panel) left the critical path, and
what remains is the capability that actually earns its keep: multiplexing many
sessions on one screen.
Not a replacement for your shell. A tab can be a Claude session or a plain shell, and they behave identically. TermHerd arranges terminals; it does not try to be one you have to relearn.
What makes it different
It reads Claude’s own files, and never writes to them. Sessions come from
walking ~/.claude/projects. Your stars, custom titles, archives and the
repositories you added by hand live in an overlay at
~/.termherd/metadata.json — TermHerd never writes under
~/.claude. Run it beside another session manager if you want; nothing it does
is destructive to the CLI’s own state.
It knows which session needs you. Every session carries an activity status
— starting, busy, idle, attention, exited — folded from what the
terminal says about itself: Claude’s OSC title stream, and for a plain shell an
OSC 133 shell-integration snippet TermHerd injects at spawn. The status drives
tab badges, the close confirmation, and the MCP wait tool. It is not a
heuristic on top of output text.
A session can drive the workspace it lives in. A Claude session launched from TermHerd gets an in-process MCP server wired into its config at spawn — no setup. It can read the whole workspace, open and split panes, type into other sessions, wait for one to go idle, screenshot the window, put a repository in the sidebar, and press TermHerd’s own key chords. That loop is what lets an agent verify a change instead of only proposing one.
The quality bar is the reason it exists. TermHerd is a replatform of an
Electron app, and the rewrite is scoped by a fixed list of defects it must fix
by construction: a headless, pure domain core (core::App::apply(Event) -> Vec<Effect>), a hexagonal crate graph where adapters depend on the core and
never the reverse, one actor per session instead of shared mutable state, typed
errors with unwrap/panic clippy-denied in the domain crates, one logging
stack, and CI gates that block a merge. See
Architecture at a glance.
Where to go next
| You want to… | Read |
|---|---|
| Get it running | Installation → Quick start |
| Learn the workspace | The sidebar, Tabs and splits |
| Look up a key | Keyboard shortcuts |
| Change a setting | settings.json |
| Let an agent drive it | Driving termherd over MCP |
| Understand how it’s built | Architecture at a glance |
This book documents shipped behaviour. Where a feature is partial, the page says so rather than describing an interface that does not exist yet. The authoritative status of every feature is
ROADMAP.md.
Installation
Requirements
A shell — and, to launch Claude sessions, the Claude Code CLI 1.0.61 or
newer on your PATH.
That floor is the CLI’s --settings flag, which TermHerd puts on every Claude
launch. It re-enables the CLI’s terminal title for that session only, and the
title is where a Claude session’s activity status comes from: without it, a
CLAUDE_CODE_DISABLE_TERMINAL_TITLE anywhere in your own settings would leave
every session reading starting forever. An older CLI rejects the flag and the
launch fails. TermHerd’s other flag, --mcp-config (the
live bridge), has been available since 0.2.75.
A plain shell needs nothing installed — see Status and attention for what it takes to give one an accurate status.
Desktop installers
Each tagged release publishes installers on the Releases page.
| Platform | File | Notes |
|---|---|---|
| macOS | TermHerd_<version>_<arch>.dmg | Open it, drag TermHerd into Applications. |
| Windows | *-setup.exe (NSIS) | |
| Linux | .deb or .AppImage | sudo apt install ./termherd_<version>_amd64.deb, or chmod +x the AppImage. |
Builds are not signed yet. On macOS, first launch needs a right-click → Open, or clearing the quarantine flag:
xattr -dr com.apple.quarantine /Applications/TermHerd.app
On Windows, SmartScreen may warn — choose More info → Run anyway.
Bare command-line binary
The same releases carry one-line installers that drop termherd into your
Cargo bin directory. Every release so far is a pre-release, which GitHub’s
/releases/latest/ shortcut skips, so name the tag — the newest one is at the
top of the Releases page:
# macOS / Linux
TAG=v0.1.0-prerelease.4
curl --proto '=https' --tlsv1.2 -LsSf \
"https://github.com/Termherd/termherd/releases/download/$TAG/termherd-app-installer.sh" | sh
# Windows
$Tag = "v0.1.0-prerelease.4"
irm "https://github.com/Termherd/termherd/releases/download/$Tag/termherd-app-installer.ps1" | iex
Verifying a Linux download
Linux release binaries carry a sigstore keyless build-provenance attestation — no signing key; the signer is the release workflow itself, via GitHub OIDC, logged in the public Rekor transparency log.
gh attestation verify termherd-app-x86_64-unknown-linux-gnu.tar.xz \
--repo Termherd/termherd
A passing check proves both integrity and that the artifact was built by this
repository’s CI. The attestation and its SHA256SUMS file come from a signing
step added after v0.1.0-prerelease.4, so that pre-release has neither — only
per-file .sha256 checksums and a combined sha256.sum, which every release
carries.
From source
The toolchain is pinned in rust-toolchain.toml (Rust 1.95.0, edition 2024);
rustup picks it up automatically.
git clone https://github.com/Termherd/termherd
cd termherd
cargo run -p termherd-app
Where TermHerd writes
Never under ~/.claude. Everything it owns lives in ~/.termherd
(%USERPROFILE%\.termherd on Windows):
| Path | Holds |
|---|---|
~/.termherd/settings.json | your settings — reference |
~/.termherd/window.json | window size and position |
~/.termherd/metadata.json | stars, archives, custom session titles, hand-added repositories |
~/.termherd/captures/ | capture-<ts>.json / .png / .gif — see Capture and record |
TermHerd is single-instance: an advisory lock file under the system temp directory. To run a second build alongside one that already holds the lock, point it at a throwaway temp dir so its lock path differs:
TMPDIR=$(mktemp -d) cargo run -p termherd-app
Quick start
Five minutes, from a cold launch to two sessions side by side with an agent driving them.
1. Launch it
Open the app you installed:
| Platform | Launch |
|---|---|
| macOS | TermHerd in Applications — Launchpad, Spotlight, or double-click it in Finder. |
| Windows | TermHerd in the Start menu, or its desktop shortcut. |
| Linux | TermHerd in your application menu (.deb), or run the .AppImage directly. |
Prefer the terminal? The bare command-line binary is on your PATH as
termherd, and from a clone it is one command:
termherd # the installed bare binary
cargo run -p termherd-app # from a clone
If you have not installed it yet, see Installation.
The window opens on the workspace: the sidebar on the left, listing every
Claude session TermHerd found by walking ~/.claude/projects, grouped by
project — plus any repository you added by hand; the tab strip across the
top; the focused terminal filling the rest.
Nothing is scanned from your source trees and nothing under ~/.claude is
written. A first run on a large history takes a moment to walk the tree — the
scan is in-memory (a SQLite cache is roadmapped, not shipped).
About the chords below
Every shortcut is rebindable, and the defaults differ by platform. The tables give both columns; the full vocabulary is in Keyboard shortcuts.
2. Open a session
Click a project in the sidebar to expand it, then click a session to open it in a tab. TermHerd resumes it through the Claude CLI.
From the keyboard, with a terminal focused:
| Action | macOS | Windows / Linux |
|---|---|---|
| New shell in the focused session’s directory | Cmd+T | Ctrl+T |
| New Claude session in that directory | Cmd+Alt+T | Ctrl+Alt+T |
| Focus the sidebar search box | Cmd+F | Ctrl+F |
Search matches session titles and indexed transcript content; a content hit shows the matched line under the row so you can tell why it matched. See The sidebar.
3. Arrange it
| Action | macOS | Windows / Linux |
|---|---|---|
| Split vertically (side by side) | Cmd+D | Ctrl+D |
| Split horizontally (stacked) | Cmd+Shift+D | Ctrl+Shift+D |
| Move focus between panes | Cmd+Shift+←↑↓→ | Ctrl+Shift+←↑↓→ |
| Next / previous tab | Ctrl+Tab / Ctrl+Shift+Tab | same |
| Jump straight to tab 1–9 | Cmd+1…9 | Ctrl+1…9 |
| Close the focused pane (a lone pane closes its tab) | Cmd+W | Ctrl+W |
| Reopen the tab you just closed | Cmd+Shift+T | Ctrl+Shift+T |
Tabs also reorder by drag-and-drop. Split panes always share their space
evenly — resizing them, by keyboard or by drag, is the remaining piece of
F-terminal-split.
4. Watch what needs you
Each tab carries an activity dot: busy while a session is working, attention when it is waiting on you (a permission prompt, a question), idle when it is done. That is also what arms the close confirmation — closing a tab whose session is mid-command asks first, closing an idle one does not. Status and attention.
5. Let a session drive the workspace
A Claude session launched from TermHerd already has the MCP control surface wired in. Ask it, inside that session:
Split this pane, run
cargo testin the new one, wait for it to finish, and tell me what failed.
It will call split_pane → run_in_session → wait_for_status →
read_terminal. That act → wait → observe loop, and everything else the
session can reach, is in Driving termherd over MCP.
6. Make it yours
There is no settings panel yet: edit ~/.termherd/settings.json
(%USERPROFILE%\.termherd\settings.json on Windows) and restart. Shell, theme,
terminal colours and font size, close-confirmation policy, sidebar density, GIF
recording budget, the editor a clicked file path opens in, and every key
binding live there — full reference.
Where to go next
- The sidebar — browsing, search, stars, plans and memory
- The terminal — selection, clickable links and paths, scrollback, zoom
- Capture and record — hand an AI assistant the app’s exact state
The sidebar: browse, search, star
The sidebar is the session browser. It lists every Claude session TermHerd
found by walking ~/.claude/projects, grouped by project — together with the
repositories you added by hand, which need no session to appear. It refreshes
live as the filesystem changes, so a session started elsewhere shows up without
a restart.
Toggle it with Cmd/Ctrl+B.
┌──────────────────────────┐
│ ◀ Hide + Add a repo │ ← add a folder the scan cannot know about
│ Search… │ ← Cmd/Ctrl+F focuses this
│ ☐ Titles only │
│ ☐ Show archived │
├──────────────────────────┤
│ ★ Favorites │
│ my-app · fix the race │
├──────────────────────────┤
│ new-repo $ 🤖 ✕ │ ← added by hand, no sessions yet
│ No sessions yet … │
│ ▾ my-app $ 🤖 │ ← launch a shell / a fresh Claude session
│ fix the race ★ ⊟ ✎ │
│ add the cache ★ ⊟ ✎ │
│ … 3 more │
│ ▸ other-repo $ 🤖 │
├──────────────────────────┤
│ Plans & memory │
│ CLAUDE.md (global) │
│ my-app/CLAUDE.md │
│ plan-2026-08-01.md │
└──────────────────────────┘
Opening a session
Click a project to expand it, then a session to resume it in a new tab.
Beside each project row are two launch buttons: $ opens a plain shell in
that project’s directory, 🤖 starts a fresh Claude session there. The same
two actions are on the keyboard as Cmd/Ctrl+T
and Cmd/Ctrl+Alt+T, which use the
focused session’s
directory instead.
Adding a repository
A project only appears once it has a Claude session, so a repository you have never opened Claude in is invisible — including the one you are about to start work in. + Add a repo puts it there anyway: pick a folder, or drop one on the window. Both do the same thing.
Drop a folder, not a file: a dropped file is ignored, so dragging one onto a terminal never quietly adds its directory.
The row that appears carries the same $ and 🤖 buttons as any other, and says
No sessions yet until it has one. When it does, it becomes an ordinary
project row — there is no second entry.
An added repository sorts to the top of the list until its first session, then takes its place by recency like everything else. A ✕ on its row removes it; the button only appears on rows you added, since a discovered project has no declaration to drop. Removing a repository that has since gained sessions drops only the addition — the project stays, because the scan still finds it.
What you pick is not always what is stored, but it is filed by exactly the rule the scan uses for a session’s working directory — that agreement is what keeps one repository on one row. A git worktree collapses onto its main checkout; a file becomes the folder holding it; everything else is stored as given, symlinks and all.
One consequence is worth knowing: a subdirectory is not climbed. Add
~/dev/app/crates/core and you get a row for that subdirectory, not for
~/dev/app — because a Claude session started there is filed the same way.
Add the directory you actually want the row for.
Additions live in ~/.termherd/metadata.json, beside stars and renames.
Nothing is written under ~/.claude.
Hovering a session shows a card with its fuller description — relative last
activity and message count (3h ago · 214 messages). The same card is what a
tab shows on hover.
Search
Cmd/Ctrl+F focuses the search box. Search runs in memory over the scan’s digests — titles, slugs, first prompts and indexed transcript text.
- A content hit renders the matched line in muted text under the session row, windowed around the match and clipped to the sidebar width, so you can tell why a row matched.
- Titles only narrows the search to titles and slugs.
- Show archived brings archived sessions back into the results.
Search is in-memory by design at this stage: the SQLite + FTS5 digest cache
(F-store-cache) is an optimisation on the roadmap, not shipped. On a very
large ~/.claude history the first scan is the slow part, not the query.
Stars, renames, archives
Three buttons on each session row:
| Button | Does |
|---|---|
| ★ | star / unstar — starred sessions collect under ★ Favorites at the top |
| ⊟ | archive (with a confirmation) — hidden unless Show archived is on |
| ✎ | rename — an inline field; Escape abandons the edit |
All three are an overlay: they are written to
~/.termherd/metadata.json, never under ~/.claude. Nothing you do in the
sidebar changes what the Claude CLI sees.
Density
Long projects fold: past sidebar.session_limit sessions (default 5) the tail
collapses behind a … N more expander, and show less folds it back. Set the
limit to 0 to always show every session —
settings.json.
Plans & memory
The bottom section lists Claude’s plan files (~/.claude/plans/*.md) and the
memory files: the global ~/.claude/CLAUDE.md and each project’s CLAUDE.md.
Clicking one loads it off-thread and opens it in the main pane, replacing the
terminal view until you close it.
The doc pane can edit — 💾 save, with a • modified marker while there
are unsaved changes and saved after a write. Two ways out, and they behave
identically: the ✕ close button, or Escape. Both discard unsaved
edits without asking — a known rough edge, tracked as
#248.
When nothing is open
With no session open, the main pane shows a welcome card: how many sessions in
how many projects were found, and the two ways to start — the $ / 🤖 buttons
beside a project, or a click on a session to resume it.
Tabs and splits
Every session you open is a tab. Every tab holds a pane tree — one terminal, or many, split vertically and horizontally.
┌ my-app ●busy ┬ tests ○idle ┬ notes ────────────────┐
├──────────────┴─────────────┴───────────────────────┤
│ │ │
│ claude (my-app) │ $ cargo test │
│ │ │
│ ├───────────────────────────┤
│ │ │
│ │ $ git log --oneline │
│ │ │
└────────────────────────┴───────────────────────────┘
mod+D splits vertically · mod+Shift+D horizontally
(mod = Cmd on macOS, Ctrl on Windows and Linux)
Tabs
| Action | macOS | Windows / Linux |
|---|---|---|
| Next / previous tab | Ctrl+Tab / Ctrl+Shift+Tab | same |
| Jump to tab 1–9 | Cmd+1…9 | Ctrl+1…9 |
| New shell here | Cmd+T | Ctrl+T |
| New Claude session here | Cmd+Alt+T | Ctrl+Alt+T |
| Reopen the tab you closed | Cmd+Shift+T | Ctrl+Shift+T |
| Close focused pane | Cmd+W | Ctrl+W |
Jump-to-tab is matched by physical key position, not by the character the
key produces. On AZERTY and QWERTZ, where the number row produces &, é, …
without Shift, Cmd/Ctrl+1 still lands on tab 1.
Each tab carries its own activity dot (see
Status and attention), a title derived from its session, and a
× to close it. Hovering a tab shows the session’s fuller description — the
same card the sidebar shows.
Tabs reorder by drag-and-drop. Press a tab and drag it onto another slot: the carried tab fades, the drop slot is outlined, and the reorder commits on release. A plain click still just activates the tab. The order lives in the pure workspace model — the tab strip holds only transient pointer state, so there is no second, rival tab tree to drift out of sync.
Splits
| Action | macOS | Windows / Linux |
|---|---|---|
| Split vertical (side by side) | Cmd+D | Ctrl+D |
| Split horizontal (stacked) | Cmd+Shift+D | Ctrl+Shift+D |
| Focus a neighbour | Cmd+Shift+←↑↓→ | Ctrl+Shift+←↑↓→ |
A split opens a fresh shell beside the focused pane. Directional focus walks the pane tree geometrically — Cmd/Ctrl+Shift+→ goes to the pane on the right, whatever the nesting.
Closing the last pane in a tab closes the tab.
Drag-resize is not shipped yet. Panes divide their space evenly; the
remaining piece of F-terminal-split is a draggable divider. Two extra focus
actions, focus-next and focus-prev, exist in the keymap with no default
chord — bind them yourself if you prefer cycling to directional movement
(settings.json).
Closing, and what asks first
Closing a tab, and quitting the app, are each governed by their own
confirmation policy — alwaysConfirm, confirmWhenActive (the default), or
noConfirmation. Under the default, a tab whose session is mid-command asks
before closing; an idle one closes silently. Quitting names how many sessions
will be force-stopped.
A pane whose shell exits cleanly closes itself; one whose shell exited with a failure stays on screen so you can read what happened.
The terminal
Each pane is a real PTY running a real program — a login shell, or the Claude CLI — rendered on a canvas. It is a terminal, not a transcript viewer: full escape-sequence handling, scrollback, selection, colours.
Selection and clipboard
Drag with the mouse to select; a double-click selects the word or filename under the pointer; Shift+click extends the current selection. The chords that reach the clipboard:
| macOS | Windows / Linux | |
|---|---|---|
| Copy selection | Cmd+C | Ctrl+Shift+C |
| Paste | Cmd+V | Ctrl+V or Ctrl+Shift+V |
Copy/paste is the one binding that is deliberately irregular per platform:
on Windows and Linux Ctrl+C must stay the interrupt.
Ctrl+C sends SIGINT on every platform.
Mouse gestures
The two classic terminal conventions are available, both off by default — so nothing reaches the clipboard unless you asked for it:
"terminal": { "copy_on_select": true, "paste_on_right_click": true }
copy_on_select— releasing a drag, or double-clicking a word, copies the selection outright. With it off the selection still highlights and waits for the copy chord, which asks the terminal for its current selection — even one scrolled out of view — so the text you copy is the text you just selected, never what you copied last. With nothing selected the chord does nothing — in particular in a pane whose program owns the mouse (below), where the program’s own copy of a drag stays on the clipboard untouched.paste_on_right_click— a right-click pastes into the pane under the pointer, which need not be the focused one, and is bracketed when that pane asked for bracketed paste. The click also focuses that pane, so the keys you type next go where you just pasted. A confirmation prompt refuses it, exactly as it refuses the paste chord — the pointer is not a way past a prompt.
When the program reads the mouse
A full-screen program that turns mouse reporting on — Claude Code’s /diff
and /resume, vim, lazygit, fzf, less, tmux with mouse on — owns the mouse
while it runs. Clicks, drags and (when it asked for them) bare moves go to the
program, so clicking an entry in /resume selects it and clicking in vim
places the cursor; the terminal draws no selection of its own over it, and a
right-click reaches the program instead of pasting. The wheel already worked
this way.
Hold Shift to take the mouse back: Shift+drag selects text from a mouse-mode program exactly as a plain drag does at a shell, and the copy gestures above apply to it. The link modifier does the same for a Ctrl/Cmd+click on a path or URL, which opens it rather than reaching the program. Neither modifier is reported to the program, so it never sees the click it lost.
See settings.json.
Scrollback
The wheel scrolls back through history. Cmd/Ctrl+Up jumps to the top of the buffer, Cmd/Ctrl+Down back to the bottom.
Zoom
Cmd/Ctrl + + / - / 0
steps the font size up, down, and back to the configured base. Zoom is a
runtime state — it does not rewrite terminal.font_size in your settings.
Three chords are bound to zoom-in, not one: mod+=, mod+plus and
mod+shift+plus. = is the unshifted face of the + key on QWERTY and the
unshifted key on AZERTY, so between them the same gesture works across layouts.
Clickable links
URLs in terminal output (http, https, file, ftp) are detected per row.
Hold Cmd/Ctrl: the link under the pointer underlines and
the cursor becomes a hand; Cmd/Ctrl+click opens it in
your OS default handler. Trailing prose punctuation and unbalanced brackets are
trimmed from the match.
Hidden hyperlinks work the same way. A program can print a label over a
URL it never shows — the OSC 8 escape, which gh, ls --hyperlink, cargo
and Claude Code’s rendered markdown all emit — so a table cell reading #76
may point at the full issue URL. termherd carries that target with the grid:
hover the label and it underlines, click it and the hidden URL opens. Where a
label sits over a hyperlink and looks like a URL itself, the hyperlink wins —
it is what the program meant, the text is only its face.
Clickable file paths
The same gesture opens file paths — the payoff being that cargo test
prints crates/pty/src/grid.rs:184, and you click it.
Detection is syntactic, then checked against the disk. A bare word with no
separator and no extension is never a candidate (otherwise every word under the
pointer would cost a stat). A :line[:column] suffix is split off the target
before resolution. URL cells are masked out so the two detectors never light
the same columns.
Resolution walks candidate roots innermost first, and that order is the
disambiguation rule — cargo, git and pytest print relative to different
roots:
- the session’s live working directory (which follows your
cds), - the repository containing it,
- the directory the session was launched from.
A path underlines only once it has resolved. An underline that turns out to point at nothing is worse than an underline one frame late.
What it opens the file with
By default, the OS default handler — and that has two consequences worth
knowing, both of which the open setting removes.
A line number cannot be honoured. Detection splits :184 off the target
and carries it all the way through, but “open this file” is all the OS handoff
can express.
Executable-by-association files are neither underlined nor opened. Handing
a path to the OS means “do what the association says”, and for a program that
means run it — an ls of an untrusted clone is enough to put payload.app on
screen. The refusal happens where the resolved path arrives, so hover and click
can never disagree: what will not open does not underline either, and you see
the refusal before you click. It is a mitigation, not a guarantee: on macOS and
Linux the set of extensions that execute is small and closed, but on Windows
the association table maps .js, .py and every installed language onto an
interpreter, and excluding those would refuse exactly the source files the
feature exists to open.
Naming an editor removes both. Set open.command with {path}, {line}
and {col} templates — see settings.json — and
the click lands on the right line. An explicit editor consults no association
either, so the refusal above lifts and those files open like any other. Give a
GUI editor: the child’s standard streams are closed, so a terminal editor
would start invisible and unkillable. The command runs as argv, never through a
shell, and it is split on whitespace before {path} is filled in — so a
filename containing spaces or & can never become a second argument.
One limit remains, known and accepted: a path that wraps across two lines is not detected, the same limit URLs have.
Colours and font
The terminal grid keeps its own colours, independent of the app chrome theme.
Start from a built-in scheme (solarized-dark, solarized-light,
gruvbox-dark, gruvbox-light) and override any slot — foreground,
background, cursor, and the 16 ANSI colours. A malformed colour degrades alone,
with a logged warning, instead of failing the file. See
settings.json.
What the shell announces
TermHerd tracks the session’s working directory from the shell’s own OSC 7
announcements, so a pane’s cwd follows your cds rather than reporting the
directory it was launched in forever. That live cwd is what
Cmd/Ctrl+T opens a new shell in, what path
resolution tries first, and what snapshot reports over
MCP.
Status and attention
Running many sessions is only useful if you can tell, at a glance, which one needs you. Every session carries one of five activity statuses:
| Status | Means |
|---|---|
starting | spawned, nothing heard from it yet |
busy | running a command / thinking |
idle | at a prompt, waiting for input |
attention | actively wants you — a permission prompt, a question |
exited | the program is gone |
The status drives the tab activity dot, the focused-terminal badge, the
close confirmation, and the MCP wait_for_status
tool. One value, four readers.
Where the signal comes from
Status is folded from what the terminal says about itself — not guessed from output text.
Claude sessions report through the CLI’s OSC title stream. Two things are needed for that stream to exist, and TermHerd arranges both:
- The CLI only emits its busy/idle/attention stream when it detects iTerm2 as
the host, so the PTY adapter advertises
TERM_PROGRAM=iTerm.app(with a version) on every spawned session. - A
CLAUDE_CODE_DISABLE_TERMINAL_TITLEin your own~/.claude/settings.jsonwould silence it entirely, and thatenvblock outranks the environment TermHerd spawns with. So a Claude launch passes a private--settingsoverlay, which outranks it in turn and merges with — never replaces — your settings. This is why the CLI floor is 1.0.61.
The CLI’s own product name (✳ Claude Code), which it reports until it has
something session-specific to say, is ignored: a tab would otherwise trade its
project name for the program’s.
Plain shells get an injected OSC 133 shell-integration snippet, whose prompt and command marks fold into the same status:
| Shell | How | Always on? |
|---|---|---|
| zsh | a private ZDOTDIR | ✅ yes |
| bash | --rcfile | only when named in settings.shell |
| fish | --init-command | only when named in settings.shell |
Each recipe replays your startup files first, so your prompt and aliases are untouched.
Bash and fish need their snippet as a command-line argument, and TermHerd
will not add one to the platform’s default program — that would demote your
login shell to an ordinary one and change which startup files run. So: name
your shell explicitly in settings.json to get the finer marks. zsh needs no
argument and is always integrated.
Where injection cannot apply — an unknown shell, an unwritable temp
directory — the PTY’s foreground process group stands in. It is retired for
good by the first real mark or Claude signal, so it can never contradict what
the terminal says about itself. Windows ConPTY exposes no foreground process
group, so a shell with neither route stays on starting there.
Close confirmation
Closing is governed per action — tab and app — by one of three policies:
| Policy | Behaviour |
|---|---|
alwaysConfirm | always ask |
confirmWhenActive | default — ask only while a session runs a foreground process |
noConfirmation | never ask |
Under the default, “runs a foreground process” means busy or attention.
That is why the status work matters beyond the badges: while every shell sat on
starting, a shell running a long command closed without asking.
Quitting names the cost: “Quit TermHerd? 3 open session(s) will be force-stopped — any running work is lost.” Every non-exited session dies on quit, Claude or shell.
Every confirmation is answerable from the keyboard — Escape to cancel, Enter to confirm — which is also what makes them reachable from MCP.
Capture and record
Two shortcuts exist for one job: handing an AI assistant — or a bug report — the app’s exact state, without asking a human to describe it.
| macOS | Windows / Linux | Writes | |
|---|---|---|---|
| Capture state | Cmd+Shift+S | Ctrl+Shift+S | capture-<ts>.json + capture-<ts>.png |
| Record (start / stop) | Cmd+Shift+R | Ctrl+Shift+R | capture-<ts>.gif |
Everything lands in ~/.termherd/captures/. <ts> is a UTC
YYYYMMDD-HHMMSS-mmm stamp, so the latest capture is the highest-named
file — an assistant finds it by sorting the directory, with no clock of its
own.
The state dump
capture-<ts>.json is a diffable dump of the whole workspace: focus, the
resolved config, the sidebar, every tab with its panes (each pane’s stable
handle, kind, cwd, status), and the focused terminal’s visible text.
It is the same model the MCP snapshot tool
reports, taken under a fixed full filter — one model, two readers, so a field
never means one thing on disk and another on the wire.
Because it is text, no vision is needed to read it:
> Read the newest file in ~/.termherd/captures/ and tell me
> why the second pane shows no prompt.
The screenshot
capture-<ts>.png is the real window pixels, for the render, colour and glyph
bugs a text dump cannot show. It is the companion to the JSON, not a
replacement — reach for it when the question is visual.
The screencast
Cmd/Ctrl+Shift+R starts a GIF
screencast; press again to stop, or let it auto-stop at the cap. Defaults: 8
fps, 30 s, 0.5× scale, all configurable under record in
settings.json.
Motion is what a still cannot carry: a gesture that half-works, a flicker, a focus that lands on the wrong pane.
How it stays honest
Capture is pure in the domain core — an Event::Capture in, an
Effect::Capture out — and every piece of I/O (the clock, the JSON and PNG
encoding, the files) lives in the GUI adapter. The GIF encoder runs on its own
thread, so recording does not stutter the UI or itself.
The recording state machine uses frames as its time proxy, not a clock, which is what keeps it testable without one.
Keyboard shortcuts
Every shortcut is an action with a kebab-case name and a default chord.
Rebind any of them in the keys section of
settings.json; an override replaces that action’s
default, and unlisted actions keep theirs.
Below, mod is the platform primary modifier: Cmd on macOS,
Ctrl everywhere else. It is a shorthand for reading this table —
not valid chord syntax. Write concrete modifiers in your own bindings.
The full action vocabulary
Tabs
| Action | Default | Does |
|---|---|---|
next-tab | ctrl+tab | next tab (Ctrl on every platform) |
prev-tab | ctrl+shift+tab | previous tab |
activate-tab-1 … -9 | mod+1 … mod+9 | jump to tab N |
new-shell-here | mod+t | new shell in the focused session’s directory |
new-claude-session-here | mod+alt+t | new Claude session in that directory |
reopen-closed-tab | mod+shift+t | reopen the tab you just closed |
close-focused | mod+w | close the focused pane; a lone pane closes its tab |
open-new-session | (unbound) | reserved — no surface yet |
activate-tab-N is matched by physical key position, so it lands on the
same keys on AZERTY and QWERTZ, where the number row produces &, é, …
without Shift.
Splits and focus
| Action | Default | Does |
|---|---|---|
split-vertical | mod+d | split side by side |
split-horizontal | mod+shift+d | split stacked |
focus-left | mod+shift+left | focus the pane to the left |
focus-right | mod+shift+right | … to the right |
focus-up | mod+shift+up | … above |
focus-down | mod+shift+down | … below |
focus-next | (unbound) | cycle forward through panes |
focus-prev | (unbound) | cycle backward |
Terminal
| Action | Default (macOS) | Default (Windows / Linux) |
|---|---|---|
copy | cmd+c | ctrl+shift+c |
paste | cmd+v | ctrl+v, ctrl+shift+v |
scroll-top | cmd+up | ctrl+up |
scroll-bottom | cmd+down | ctrl+down |
zoom-in | cmd+=, cmd+plus, cmd+shift+plus | ctrl+… (same three) |
zoom-out | cmd+- | ctrl+- |
zoom-reset | cmd+0 | ctrl+0 |
Copy/paste is the one pair whose default is irregular per platform: on Windows
and Linux Ctrl+C must stay the interrupt, so copy takes
Shift. Ctrl+C sends SIGINT everywhere.
Zoom-in binds three chords because = is the unshifted face of the + key on
QWERTY and the unshifted key on AZERTY; between them the same gesture works
across layouts.
App
| Action | Default | Does |
|---|---|---|
focus-search | mod+f | focus the sidebar search box |
toggle-sidebar | mod+b | show / hide the sidebar |
capture | mod+shift+s | write a state dump + screenshot |
toggle-record | mod+shift+r | start / stop a GIF screencast |
Not in the keymap
| Gesture | Does |
|---|---|
| Ctrl+C | interrupt (SIGINT) — passed through to the program |
| Escape | cancel an open prompt, rename or doc pane |
| Enter | confirm an open prompt |
| Drag a selection | select; copies too with terminal.copy_on_select (off by default) |
| Right-click | paste, with terminal.paste_on_right_click (off by default) |
| Wheel | scroll back through history, or the wheel event to a program reading the mouse |
| Click, drag, right-click in a program reading the mouse | the event goes to the program — vim, lazygit, Claude Code’s /resume; nothing is selected or pasted locally |
| Shift+drag in such a program | the terminal’s own selection, as a plain drag is at a shell |
| Cmd/Ctrl+click | open a URL, hidden (OSC 8) hyperlink or file path under the pointer |
| Drag a tab | reorder it |
Escape and Enter are bound to no action on purpose: they belong to whichever overlay is open. That is also what makes them the only way an MCP caller can answer a prompt it armed.
Chord syntax
Case- and order-insensitive. Modifiers ctrl, shift, alt, cmd, joined to
a key with +. Aliases: control for ctrl, option for alt, and
super, logo, win or meta for cmd. The + key itself is spelled
plus, since a literal + is the separator:
"keys": {
"copy": "ctrl+y",
"paste": ["ctrl+shift+v", "shift+insert"],
"activate-tab-1": "alt+1"
}
One chord or a list of chords per action. Unknown action names and unparsable chords are logged and skipped — they do not invalidate the rest of the file.
Reading the live keymap
The stdio MCP server publishes the action catalogue — each
action with its default chords and the override settings.json sets for it,
if any — as a resource at termherd://keys/schema. It is generated from the
same in-code table this page describes, so it cannot drift from the binary you
are running. Two gaps: the activate-tab-N family is not listed, and copy /
paste show no default, because theirs differ per platform and are set
outside that table — this page has them.
settings.json
~/.termherd/settings.json (macOS, Linux)
%USERPROFILE%\.termherd\settings.json (Windows)
There is no in-app settings panel yet: edit the file and restart.
The annotated template with every option, its default and its meaning is
docs/settings.example.jsonc
— copy the blocks you want and strip the comments. The real file is strict
JSON: no comments, no trailing commas.
How it loads
Read once at startup, and defensively:
- Every field is optional. A missing file, a missing field, or a corrupt file falls back to built-in defaults — settings never block startup.
- Out-of-range values clamp instead of failing the file.
- One bad value — a typo’d colour, an unknown action name — degrades alone with a logged warning. The rest of the file still applies.
Two neighbouring files are TermHerd’s, not yours to edit: window.json (size
and position — a position left off every connected monitor is dropped, so the
window re-centers instead of opening out of reach) and metadata.json (stars,
archives, custom titles, and the repositories you added by hand).
Options
shell
The program launched for each session. Omit the block, or set it to null, for
the platform default login shell. args is optional.
"shell": { "program": "pwsh", "args": [] }
Naming your shell here has a side effect worth knowing: bash and fish get their OSC 133 shell-integration snippet — and therefore an accurate activity status — only when named explicitly. See Status and attention.
theme
"dark" (default) or "light". GUI chrome only — sidebar, tab strip, buttons.
The terminal grid keeps its own colours.
close
Per-action close confirmation. Both keys default to "confirmWhenActive".
"close": { "tab": "confirmWhenActive", "app": "confirmWhenActive" }
| Value | Behaviour |
|---|---|
alwaysConfirm | always ask |
confirmWhenActive | ask only while a session runs a foreground process |
noConfirmation | never ask |
terminal
"terminal": {
"font_size": 14,
"copy_on_select": false,
"paste_on_right_click": false,
"colors": {
"scheme": "solarized-dark",
"foreground": "#839496",
"background": "#002b36",
"cursor": "#839496",
"palette": ["#073642", "#dc322f", "…16 entries…"]
}
}
| Key | Default | Notes |
|---|---|---|
font_size | 14 | pixels, clamped to 6–40. The zoom chords step from here at runtime without rewriting it. |
copy_on_select | false | a drag release or a double-click copies the selection outright, no chord needed |
paste_on_right_click | false | a right-click pastes into the pane under the pointer, not necessarily the focused one |
colors.scheme | built-in | solarized-dark, solarized-light, gruvbox-dark, gruvbox-light |
colors.foreground / .background / .cursor | from the scheme | "#rrggbb"; the # is optional |
colors.palette | from the scheme | the 16 ANSI colours — normal 0–7, then bright 8–15 |
Every colour field is optional and overrides the scheme it starts from. Fewer than 16 palette entries override the head of the list; entries past 16 are ignored.
sidebar
"sidebar": { "session_limit": 5 }
Sessions shown per project before the tail folds behind a … N more expander.
0 shows every session.
record
The GIF screencast budget
(Cmd/Ctrl+Shift+R).
Values clamp: fps 1–60, max_seconds 1–600, scale 0.1–1.0.
"record": { "fps": 8, "max_seconds": 30, "scale": 0.5 }
mcp
Whether the live bridge’s prompt_in_session may prompt another Claude
session — one agent driving another. Off by default; a shell session is never
gated. A single call can opt in on its own with allow_claude_nesting: true
(see The live bridge).
"mcp": { "allow_claude_nesting": false }
open
The command a Cmd/Ctrl-clicked file path opens in. Omit
the block to hand the file to the OS default handler (open / explorer /
xdg-open) — the default, which cannot honour a line number.
"open": { "command": "code -g {path}:{line}:{col}" }
Templates: {path} (required), {line}, {col}. A path the terminal printed
without a position opens at 1:1, so one command stays well-formed either way.
| Rule | Why |
|---|---|
| Give a GUI editor | the child’s standard streams are closed, so vim would start invisible and unkillable. Reach one through a terminal emulator: ["wezterm", "start", "--", "vim", "+{line}", "{path}"] |
| Run as argv, never a shell | the string is split on whitespace before {path} is filled in, so a filename with spaces or & can never become a second argument. Use the array form when the program’s own path has spaces |
No ~, no shell lookup | the program is spawned directly; write the full path. An app launched from Finder/Explorer inherits a minimal PATH that often lacks code |
Windows: code.cmd | a direct spawn only ever appends .exe, so a bare "code" is not found |
| A placeholder in the program name is refused | what the terminal printed chooses the file, never the executable |
An empty command, one without {path}, or one with an unknown placeholder is
logged and ignored — the OS handoff applies instead. A command that fails to
start raises a desktop notification.
Configuring this also lifts a restriction: with no command, a path the OS
would run rather than show (.app, .exe, .desktop) is neither underlined
nor opened, because the handoff obeys the file association. An explicit editor
consults no association, so those files open like any other.
keys
Keyboard overrides — one chord or a list per action, replacing that action’s default. The full vocabulary and its defaults are in Keyboard shortcuts.
"keys": {
"copy": "ctrl+y",
"paste": ["ctrl+shift+v", "shift+insert"],
"activate-tab-1": "alt+1"
}
Reading and writing it from a Claude session
The stdio MCP server exposes a subset of these options
as list_options / set_option, so you can ask “what can I configure here?”
or “switch me to a light theme” from any Claude session.
The eight ids it covers today: theme, shell.program, shell.args,
terminal.colors.scheme, terminal.colors.foreground,
terminal.colors.background, terminal.colors.cursor,
terminal.colors.palette. Two of them — shell.program and shell.args —
are read-only over MCP: they name what TermHerd executes at the next
launch, so an agent may read them but never set them. The close, sidebar,
record, open, mcp, keys, terminal.font_size,
terminal.copy_on_select and terminal.paste_on_right_click blocks are
file-only for now; keys is published as a read-only resource.
A set_option write lands in settings.json and applies on restart, like
any other edit to the file.
A complete example
{
"shell": { "program": "/bin/zsh" },
"theme": "dark",
"close": { "tab": "confirmWhenActive", "app": "alwaysConfirm" },
"terminal": {
"font_size": 15,
"copy_on_select": true,
"paste_on_right_click": true,
"colors": { "scheme": "gruvbox-dark" }
},
"sidebar": { "session_limit": 0 },
"record": { "fps": 10, "max_seconds": 20, "scale": 0.5 },
"open": { "command": "code -g {path}:{line}:{col}" },
"mcp": { "allow_claude_nesting": false },
"keys": {
"toggle-sidebar": "ctrl+alt+b",
"focus-next": "ctrl+alt+right"
}
}
Two surfaces
TermHerd exposes itself to Claude sessions over MCP, so a session can read and drive the workspace it is running in. There are two servers, and which one you get depends on how the session started.
┌──────────────────────────────┐
│ TermHerd (GUI) │
│ ┌────────────────────────┐ │
in-proc │ │ core::App (state) │ │
loopback │ └───────────▲────────────┘ │
┌──────┼──────────────┘ │
│ │ the LIVE BRIDGE │
│ │ 17 tools · the running │
│ │ workspace │
│ └──────────────────────────────┘
│
│ wired in at spawn, per-session token
▼
┌─────────────────────┐ ┌──────────────────────────┐
│ a Claude session │ │ any Claude session │
│ LAUNCHED BY termherd│ │ anywhere │
└─────────────────────┘ └────────────┬─────────────┘
│ you register it
▼
┌──────────────────────────┐
│ termherd-mcp (stdio) │
│ 2 tools · settings.json │
└──────────────────────────┘
| The live bridge | The stdio server | |
|---|---|---|
| Reaches | the running workspace | the settings file |
| Setup | none — wired in at spawn | you register termherd-mcp yourself |
| Available to | sessions launched from TermHerd | any Claude session |
| Transport | in-process, loopback, per-session bearer token | JSON-RPC over stdio |
| Surface | 17 tools | 2 tools + 2 resources |
| Needs the app running | ✅ yes | ❌ no |
The gap between them
Neither surface lets an agent running outside termherd drive the running workspace. The live bridge holds the sessions, but only a session it spawned holds the endpoint and token to reach it; the stdio server is reachable from anywhere and sees only the settings file.
So the terminal that launched termherd cannot ask it for a screenshot the app already knows how to take. Closing that is #267 — discovery, not new tools.
Which one you want
- “Split this pane and run the tests” → live bridge. It only exists inside a session TermHerd started.
- “Switch me to a light theme” → stdio server. It edits the file; the change applies on restart.
Design notes
Sessions are addressed by a stable handle — the runtime session id —
never by the Claude resume_id, which re-keys on a fork or a plan-accept.
Every call is timeout-bounded. A wedged shell surfaces as a tool error, never a hang. Waits have a default and a hard cap, so no call, however parameterised, can park a caller indefinitely.
The domain core has no MCP awareness at all. Every mutation goes through an
Event that already existed for the keyboard. That is the invariant behind the
whole surface: an MCP caller cannot reach a state the keyboard cannot.
The live bridge
A Claude session launched from TermHerd is wired to an in-process MCP
server on loopback, with a per-session token, injected into its mcpServers at
spawn. Nothing to configure — if you started the session from the app, the
tools are there.
Its private files — the mcp config carrying that bearer token, the shell-integration directory, the settings overlay — are deleted when the session is torn down.
The tools
Perception
| Tool | Args | Returns |
|---|---|---|
list_sessions | — | { sessions: [...] } — each row a live session: stable handle, tab title, cwd, kind (shell / claude), resumed Claude id, status |
snapshot | sections, terminals, focused_terminal, text_lines | the whole state: config, sidebar, tabs and panes |
read_terminal | session, lines | { text, rendered } |
screenshot | max_width | the window as a PNG |
Every tool answers a JSON object: MCP clients reject anything else on their
schema check, which is why list_sessions puts its rows in a sessions field
rather than answering the array itself.
snapshot is light by default: structure only, no terminal text. Scope
text to named handles with terminals (or set focused_terminal: true for the
focused pane, when you do not know its handle yet), or pass sections (any of "config",
"sidebar", "tabs") to narrow it further. text_lines defaults to 40. Read
the structure first, then ask for a handle — that ordering is why the filter
exists.
read_terminal’s rendered: false means the session is live but its screen
has not been drawn yet. Retry — do not give up on the handle.
screenshot is the pixel companion for render, colour and glyph questions text
cannot answer. Reach for it last: a default-bound window is on the order of
200 kB of PNG and a third more again as base64, where a snapshot is a few
hundred bytes. max_width defaults to 1200 (clamped 64–4096); a total-pixel
ceiling also bounds tall windows the width alone would not, the frame is
area-averaged down rather than nearest-sampled — which is what keeps terminal
glyphs legible at the ~0.4× a retina window is reduced by — and a window
smaller than the bound is never upscaled. The reported width/height are
what you actually received. A headless run has no window and says so as a
tool-level error; the text reads keep working.
Action
| Tool | Args | Notes |
|---|---|---|
open_session | project, kind | kind is "shell" (default) or "claude"; omit project for the home dir |
split_pane | direction, pane | "vertical" (default) or "horizontal"; omit pane for the focused one |
focus_pane | session | |
rename_tab | tab, title | tab is the 0-based index snapshot reports; a blank title reverts to the derived one |
close_pane | pane | a lone pane is its whole tab, which closes |
run_in_session | session, text | include a trailing newline to submit |
mouse_in_session | session, kind, col, row, button | a mouse event at a cell of the terminal; see below |
add_repo | path | put a repository in the sidebar before it has any session |
forget_repo | path | drop an addition; the row survives on its sessions |
Each returns the resulting focused_handle (null when the workspace is now
empty).
The two repo tools answer about a sidebar row rather than about focus, so they add four fields:
| Field | Means |
|---|---|
repo_path | the normalised key the row is filed under |
declared | whether it is currently a hand-added repository |
session_count | sessions on that row right now |
in_sidebar | whether a row is there at all |
The last two report membership, not what the window happens to be drawing:
a search left in the box, or the archived filter, changes neither. Otherwise a
successful add_repo would read back as a failure for no reason the caller
could see.
repo_path is the one to keep. add_repo files a path by exactly the rule
the scan uses for a session’s working directory — a worktree collapses onto
its main checkout, a file becomes its parent directory, everything else is kept
as given (symlinks included, and not climbed to a repository root). Two
spellings of one directory are one key: a trailing slash, a ./, and forward
slashes on Windows all normalise away, since none of them is a spelling the
scan can produce. That agreement is what stops one repository from occupying
two rows, so address the row afterwards with what came back, not with what you
sent. A path that does not exist, or a relative one, is rejected.
forget_repo is the asymmetric one: forgetting a repository that was never
added is not an error, and forgetting one the scan still reports leaves the
row standing. Read in_sidebar to tell the two outcomes apart — false means
it is gone, true with declared: false means it lives on its sessions.
The pointer, inside a terminal
mouse_in_session is the pointer counterpart of run_in_session: it places
one mouse event inside a session’s terminal. It is addressed by cell, not
by pixel — a terminal is a grid, and a grid is what a mouse report carries —
so an agent with no screen coordinates can still point.
| Arg | Values |
|---|---|
kind | press, release, click, drag, move |
col, row | 0-based cells of the visible screen |
button | left (default), middle, right |
The answer adds a pointer field saying what the terminal did:
pointer | Means |
|---|---|
forwarded | the program in the session reads the mouse and was sent the event |
selection | no program reads the mouse; the event drove the terminal’s own text selection |
ignored | it drove nothing — a release, a move or a non-left button with no program reading the mouse, or a motion the program’s mouse mode does not cover |
Which of the first two you get is the program’s choice, not yours. A
full-screen program that turns mouse reporting on — Claude Code’s /diff and
/resume, vim, lazygit, fzf, less — owns the mouse while it runs: every event
goes to it in the encoding it negotiated, and the terminal selects nothing of
its own. Follow a forwarded with wait_for_status / read_terminal to see
what the program made of it, as after run_in_session. Mouse reporting comes
in three widths, and a motion the program did not ask for is dropped rather
than selected: click-only reporting takes presses and releases, drag reporting
adds motion with a button held, and motion reporting takes every move.
At a plain shell, or any program not reading the mouse, the same calls drive
the terminal’s selection. A drag is two calls — press at one cell, then
drag at another — and the text between them is selected, both cells
included. Read it back with the copy action (run_action), which puts the
selection on the clipboard. A bare click clears the selection. A cell outside
the pane’s geometry, or a session that has not rendered yet, rejects the
whole call before anything applies, naming the geometry so you can retry
inside it.
The report carries no modifier keys: a press is a plain press whatever the
human’s keyboard is doing. The same split governs a human’s mouse over the
pane — see When the program reads the mouse.
Synchronisation
| Tool | Args | Returns |
|---|---|---|
wait_for_status | session, statuses, timeout_ms | { status, timed_out } |
prompt_in_session | session, text, statuses, lines, timeout_ms, allow_claude_nesting | { status, timed_out, text, rendered, focused_handle } — prompt, wait and read in one round trip |
statuses defaults to idle-or-attention — the two a caller waiting on a
command actually wants. timeout_ms defaults to 30 000 and is capped at
300 000. prompt_in_session is the composed agent-loop tool: prompt a session,
wait for its activity status to settle, and read back its terminal text in a
single round trip. Prompting a shell session is enabled by default; prompting
a nested Claude session requires opt-in via mcp.allow_claude_nesting setting or
the allow_claude_nesting: true parameter.
A timeout is not an error. On expiry the reported status is the session’s
current one, and timed_out is true. And a session that exits settles
the wait whatever you asked for — it can no longer reach your target. Both
behaviours exist so a wait can never silently park you.
The keyboard
press_keys and run_action drive TermHerd’s own interface — see
Driving the keyboard.
The loop: act → wait → observe
run_in_session returns as soon as the text is sent. It does not wait for
the command.
run_in_session(session, "cargo test\n")
│
▼
wait_for_status(session, ["idle", "attention"])
│
▼
read_terminal(session, lines: 60)
Alternatively, use prompt_in_session to run all three steps in one round trip.
Do not poll snapshot in a loop. It races the transition you are watching
for — that race is exactly why the wait tool exists.
A worked example, from inside a session TermHerd launched:
1. split_pane({ direction: "vertical" }) → focused_handle: "7"
2. prompt_in_session({ session: "7",
text: "cargo test --workspace\n",
timeout_ms: 300000,
lines: 80 }) → { status: "idle",
timed_out: false,
text: "...",
rendered: true,
focused_handle: "7" }
Errors and refusals
- An unknown handle, an out-of-range tab index, a non-numeric handle → an
invalid_paramserror naming the problem. - A malformed chord or unknown action name rejects the whole call before anything applies: half an applied sequence is worse than none, because the caller cannot tell how far it got. A pointer event outside the pane, or an unknown pointer word, is refused the same way.
- A wedged shell surfaces as a tool error, never a hang.
What is still open
Four follow-ups, and they are independent of each other:
| Gap | Issue |
|---|---|
enter commits neither rename over MCP — see Driving the keyboard. | #246 |
The doc editor discards unsaved edits when it closes, by button or by escape. | #248 |
| The bridge is reachable only from a session termherd spawned, so the launcher itself cannot drive it — see Two surfaces. | #267 |
| No pointer at TermHerd’s own interface: the sidebar, the tab strip, a split gutter. | #301 |
Driving the keyboard
Two tools reach TermHerd’s own interface — the sidebar, the tab strip, the
overlays — rather than a terminal. Typing into a session stays
run_in_session’s job.
| Tool | Takes | Tests |
|---|---|---|
press_keys | chords in settings.json syntax — "cmd+shift+s", "ctrl+tab", "escape" | the binding, resolved through the live keymap, including your overrides |
run_action | kebab-case action names — "split-vertical", "activate-tab-3" | the behaviour, skipping the keymap, so it survives a rebind |
Both take a list applied in order and return one steps entry per item, plus
the resulting focused_handle.
Why it is a real key event
A chord is dispatched as a synthesised key event fed to the app’s key handler — the whole routing ladder, not just a keymap lookup.
That is what makes Escape and Enter reachable at all: they are overlay keys, bound to no action. Without them an agent that armed a close confirmation would have no way to answer it, and would park the app until a human intervened.
The two are not equally complete, and the difference matters to a caller.
escape leaves every prompt — that is what guarantees an agent can always
back out. enter confirms the confirmation prompts but commits neither
rename, because both commit through a widget callback no synthesised event
reaches (#246).
The corollary: an open overlay consumes an MCP press exactly as it consumes a
keypress. The step reports which prompt ate it, so a caller learns why its
chord did nothing. run_action is gated on the same ladder on purpose —
neither tool may reach a state the keyboard cannot.
Reading a step
| Outcome | Means | Extra field |
|---|---|---|
ran | the ladder applied it | action — the name that ran |
inert | nothing happened | reason — see below |
overlay | an open prompt consumed it | which prompt |
typed | bound to nothing, so it reached the focused terminal | |
unbound | nothing claimed it |
ran means the shell applied the event, not that the effect was
interesting: activate-tab-9 on a single-tab workspace reports ran, because
the event was applied and absorbed. Collapsing that into inert would make the
distinction useless.
The two kinds of nothing
inert carries a reason because they call for opposite responses:
| Reason | Means | Do |
|---|---|---|
no-surface | the action is wired to nothing yet (open-new-session is the one) | stop — retrying is pointless |
no-context | a precondition was absent — nothing focused to derive a repo from, no closed tab to reopen, nothing to scroll, nothing selected to copy | create it, then retry |
Seven handlers can refuse this way, and each says so at its own refusal site.
copy refuses on the terminal’s own answer: it holds a selection or it does
not, wherever that selection has scrolled to. An MCP drag in a forwarded
pane selects nothing (it carries no Shift, so it is the program’s), so copy
after it is no-context — and the program’s own clipboard write (Claude Code
copies a drag on release) is left alone. A human’s Shift+drag there is a
selection, and copy runs on it.
Answering an overlay
escape usually cancels; enter usually confirms. Three cautions:
- On
quit-confirm,enterquits the app — killing every session and the connection you are speaking over. session-rename(the sidebar’s inline ✎ field) does not commit onenter, and neither doestab-rename: both commit through the widget’s own submit, which a synthesised key event never reaches.escapeabandons either — so you can always back out and start over — but committing a rename over MCP is a missing capability, tracked as #246.- Every other overlay is exitable from the keyboard, and a test sweep derived from the overlay enumeration — not a hand-written list — is what keeps it that way. A new overlay added without an exit fails there.
Rejection is all-or-nothing
A malformed chord or an unknown action name rejects the whole call before anything applies. The error names the offender and the syntax. This is deliberate: a half-applied sequence is worse than none, because the caller cannot tell how far it got.
The action catalogue — less the activate-tab-N family, which run_action
accepts all the same — is published by the stdio server at
termherd://keys/schema; the live bridge serves tools only, so its run_action
error message carries the syntax instead.
Example: verify a keyboard gesture end to end
run_action(["split-vertical"]) → { steps: [{ result: "ran",
action: "split-vertical" }],
focused_handle: "4" }
screenshot({ max_width: 900 }) → the pixels, to check the divider
press_keys(["cmd+w"]) → { steps: [{ result: "overlay",
overlay: "tab-close-confirm" }] }
press_keys(["escape"]) → cancelled
The stdio server
termherd-mcp is a separate, small binary that exposes TermHerd’s
configuration — so you can ask “what can I configure here?”, or “switch me
to a light theme”, from any Claude session, whether or not TermHerd is
running.
It speaks JSON-RPC over stdio and is stateless: it reads and writes
~/.termherd/settings.json, nothing else.
Registering it
cargo build -p termherd-mcp # lands in target/
Add it to your mcpServers config, pointing command at the built binary:
{
"mcpServers": {
"termherd": { "command": "/path/to/termherd-mcp" }
}
}
Tools
| Tool | Args | Does |
|---|---|---|
list_options | — | lists the configurable options with their current values |
set_option | id, value | sets one writable option; the change lands in settings.json and applies on restart |
Both speak the option id — a stable, dotted name:
| id | Kind | Writable | Values |
|---|---|---|---|
theme | enum | yes | dark, light |
shell.program | string | no | unset means the platform default login shell |
shell.args | array | no | |
terminal.colors.scheme | enum | yes | solarized-dark, solarized-light, gruvbox-dark, gruvbox-light |
terminal.colors.foreground | string | yes | "#rrggbb" |
terminal.colors.background | string | yes | "#rrggbb" |
terminal.colors.cursor | string | yes | "#rrggbb" |
terminal.colors.palette | array | yes | the 16 ANSI colours — normal 0–7, bright 8–15 |
shell.program and shell.args are read-only over MCP: their value is
what TermHerd executes for every shell session at the next launch, and an
agent must not get to choose that unattended. list_options and the schema
carry the writable flag per id, so a model can tell before it tries.
set_option refuses rather than degrades: a read-only id, an unknown id,
or a value that does not fit the option’s kind (a non-array palette, a theme
outside dark/light) answers a JSON-RPC error and writes nothing. null is
always accepted on a writable id — it unsets the option.
That is the whole write surface today. The close, sidebar, record,
open, mcp, keys, terminal.font_size, terminal.copy_on_select and
terminal.paste_on_right_click blocks of
settings.json are file-only — keys is
readable as a resource, below.
Resources
| URI | Holds |
|---|---|
termherd://options/schema | the schema of the configurable options |
termherd://keys/schema | the bindable actions, with their default and current chords |
termherd://keys/schema is generated from the same in-code action table the
keymap itself uses, so it cannot drift from the binary you are running. It is
the machine-readable form of
Keyboard shortcuts, and the catalogue the live
bridge’s run_action speaks — less the activate-tab-N family, which
run_action accepts but the resource does not list. copy and paste appear
with no default: theirs differ per platform and are set outside that table.
What it is not
It does not reach the running app: no sessions, no tabs, no terminals, no keyboard. That is the live bridge, and it exists only inside a session TermHerd launched.
It is also not F-mcp-ide-bridge — a deferred, unbuilt feature that would
run the other way round, with TermHerd as an MCP client of Claude’s IDE
bridge. docs/ARCHITECTURE.md §15 lists an mcp crate as deferred under that
name; the server described here lives in the same repository but answers a
different question.
Architecture at a glance
TermHerd is a replatform of an Electron session manager, and the rewrite is scoped by the defects it must fix by construction — a god object, races, silent catches, and an untestable design. Everything below is downstream of that.
The authoritative documents are
docs/PRD.md and
docs/ARCHITECTURE.md.
The dependency rule
A hexagonal workspace. Adapters depend on the core; the core depends on no adapter.
app ──────► core ◄────── adapters
(iced GUI) │ (scan, pty)
▼
claude
(pure codec)
| Crate | Is | Depends on |
|---|---|---|
core | the domain: headless App state machine, the tab/pane tree, the keymap, the port traits | claude only |
claude | a pure codec for the Claude CLI’s own formats — path encode/derive, JSONL digest, OSC decode | nothing |
app | the iced GUI shell; builds the adapters in main() and injects them; owns the one effect executor and the MCP control surface | core + adapters |
scan | filesystem discovery — walks ~/.claude/projects | core |
pty | the terminal adapter (portable-pty + alacritty_terminal) | core |
mcp | the stdio settings server | core |
When adding code the question is which crate does this belong in? If the answer is “the core should call this adapter directly”, the answer is wrong — add a port trait and have the adapter implement it.
The headless core
core::App::apply(Event) -> Vec<Effect>
Elm-style, and pure: no I/O, no clock, no panic. The GUI translates user
gestures into Events and performs the returned Effects. Everything testable
lives behind apply — which is also why the MCP surface
adds no new mutation path: every tool goes through an Event the keyboard
already used.
The pane tree is pure data, exhaustively unit-testable. Its mutators return
Option<()> rather than panicking when an invariant is violated — a broken
invariant surfaces as None/Err, never an unwrap.
Concurrency
One tokio runtime, actor per session: each session is owned by a task
holding its PTY handle and terminal grid, reachable only by channel. There is
no shared &mut Session. The GUI thread owns the App and applies events
single-threaded.
That structure is the fix for the session-id race the predecessor had, not a mitigation of it.
The quality bar
Non-negotiable, and CI-enforced:
unwrap,expectandpanicare clippy-denied incoreandclaude. Production paths return typed errors.unsafe_codeis denied workspace-wide. The one sanctioned exception is the macOS AppKit FFI for the Cmd+Q quit path: acfg-gated module with aSAFETY:note on every block.- No global mutable state — no
static mut, nolazy_static, no require-time singletons. Dependencies are built inmain()and injected. - One logging stack:
tracing. Noprintln!outside tests. - Function length is gated (
clippy::too_many_lines, threshold 150). A function that exceeds it on purpose carries a local allow with a rationale. - Twelve gates guard a pull request — formatting, clippy with warnings denied, the test suite, dependency licensing, unused dependencies, the crate dependency rule, intra-crate module boundaries, workflow lint, markdown lint, the roadmap, and this book. Each runs only when its own file category changed, so a docs-only change skips every Rust job; they fan into one required check, which treats a skipped gate as a pass. Portable crates are additionally built and tested on Windows on every PR.
The full CI reference is
docs/CI.md.
Toolchain
Pinned to Rust 1.95.0, edition 2024, via rust-toolchain.toml.
Contributing
The full conventions live in the repository, and they are the authority:
CONTRIBUTING.md— contribution conventionsAGENTS.md— the engineering rules: dependency rule, quality bar, how work is trackedCODING_STANDARDS.md— Tidy First, CUPID, YAGNI, TDDdocs/CI.md— every gate, why it exists, and how to mirror it
Before you push
Every gate below is blocking in CI. Mirror them locally:
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo deny check # if cargo-deny is installed
cargo machete # unused deps; if cargo-machete is installed
just check-deps # the hexagonal crate dependency rule
just check-arch # intra-crate module boundaries + OS-cfg containment
markdownlint-cli2 # markdown is gated too, including this book
CI runs each gate only when its file category changed, and they fan into one required check. A docs-only pull request skips every Rust job.
Building this book
just docs # build → docs/book/index.html
just docs-serve # live reload on http://localhost:3000
create-missing = false in book.toml means a SUMMARY.md entry for a page
that does not exist fails the build rather than silently minting an empty
page. The chapter map is a promise; that setting keeps it honest. It covers
SUMMARY.md only — a broken link inside a page still builds, so check those
by hand.
Three rules that surprise newcomers
No issue numbers in code. Not in comments, doc-comments, or test names —
git already links code to its issue, and an in-code #42 rots when issues are
renumbered. Cite issues in commit messages, PR bodies, and roadmap prose
instead.
ROADMAP.md is generated — never edit it. Edit the feature file under
.roadmap/features/, run just roadmap, and commit both. CI rebuilds and
diffs.
A user-visible change updates this book in the same PR. If what a user
sees, types or configures moves, the page describing it moves with it — not in
a follow-up. No gate catches a stale page: the book job proves the book
builds and that SUMMARY.md resolves, never that it still describes the
binary. Pages that restate what the code holds as data — the shortcut table,
the settings reference, the MCP tool tables — are the ones that rot first.
AGENTS.md carries the mapping from what you changed to what to update.
Everything written for the project is in English — commits, issues, pull requests, comments, docs. The history is bilingual because the rule arrived late; new writing is English whatever language the work was discussed in.
How work is tracked
Three layers, each owning exactly one thing:
| Layer | Owns |
|---|---|
.roadmap/ + docs/PRD.md | the what and why — features, MoSCoW bucket, shipped history |
| GitHub issues | the unit of work — scoped, actionable, typed and labelled |
| The project board | priority and order — Horizon, Class, Effort, Severity |
The published view of the first layer is
ROADMAP.md:
every feature, its MoSCoW bucket, whether it has shipped, and the reasoning
behind it. It is compiled from .roadmap/ — read it, never edit it. The board
that orders the work is internal, so ROADMAP.md is the answer to “does this
feature exist?” and the issue tracker to “is anyone on it?”.
An epic graduates from the roadmap to an issue only when it is scoped enough to act on.