Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 runningInstallation → Quick start
Learn the workspaceThe sidebar, Tabs and splits
Look up a keyKeyboard shortcuts
Change a settingsettings.json
Let an agent drive itDriving termherd over MCP
Understand how it’s builtArchitecture 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.

PlatformFileNotes
macOSTermHerd_<version>_<arch>.dmgOpen it, drag TermHerd into Applications.
Windows*-setup.exe (NSIS)
Linux.deb or .AppImagesudo 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):

PathHolds
~/.termherd/settings.jsonyour settings — reference
~/.termherd/window.jsonwindow size and position
~/.termherd/metadata.jsonstars, 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:

PlatformLaunch
macOSTermHerd in Applications — Launchpad, Spotlight, or double-click it in Finder.
WindowsTermHerd in the Start menu, or its desktop shortcut.
LinuxTermHerd 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:

ActionmacOSWindows / Linux
New shell in the focused session’s directoryCmd+TCtrl+T
New Claude session in that directoryCmd+Alt+TCtrl+Alt+T
Focus the sidebar search boxCmd+FCtrl+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

ActionmacOSWindows / Linux
Split vertically (side by side)Cmd+DCtrl+D
Split horizontally (stacked)Cmd+Shift+DCtrl+Shift+D
Move focus between panesCmd+Shift+←↑↓→Ctrl+Shift+←↑↓→
Next / previous tabCtrl+Tab / Ctrl+Shift+Tabsame
Jump straight to tab 1–9Cmd+1…9Ctrl+1…9
Close the focused pane (a lone pane closes its tab)Cmd+WCtrl+W
Reopen the tab you just closedCmd+Shift+TCtrl+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 test in 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: 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.

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:

ButtonDoes
★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

ActionmacOSWindows / Linux
Next / previous tabCtrl+Tab / Ctrl+Shift+Tabsame
Jump to tab 1–9Cmd+1…9Ctrl+1…9
New shell hereCmd+TCtrl+T
New Claude session hereCmd+Alt+TCtrl+Alt+T
Reopen the tab you closedCmd+Shift+TCtrl+Shift+T
Close focused paneCmd+WCtrl+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

ActionmacOSWindows / Linux
Split vertical (side by side)Cmd+DCtrl+D
Split horizontal (stacked)Cmd+Shift+DCtrl+Shift+D
Focus a neighbourCmd+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:

macOSWindows / Linux
Copy selectionCmd+CCtrl+Shift+C
PasteCmd+VCtrl+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.

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:

  1. the session’s live working directory (which follows your cds),
  2. the repository containing it,
  3. 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:

StatusMeans
startingspawned, nothing heard from it yet
busyrunning a command / thinking
idleat a prompt, waiting for input
attentionactively wants you — a permission prompt, a question
exitedthe 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_TITLE in your own ~/.claude/settings.json would silence it entirely, and that env block outranks the environment TermHerd spawns with. So a Claude launch passes a private --settings overlay, 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:

ShellHowAlways on?
zsha private ZDOTDIR✅ yes
bash--rcfileonly when named in settings.shell
fish--init-commandonly 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:

PolicyBehaviour
alwaysConfirmalways ask
confirmWhenActivedefault — ask only while a session runs a foreground process
noConfirmationnever 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.

macOSWindows / LinuxWrites
Capture stateCmd+Shift+SCtrl+Shift+Scapture-<ts>.json + capture-<ts>.png
Record (start / stop)Cmd+Shift+RCtrl+Shift+Rcapture-<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

ActionDefaultDoes
next-tabctrl+tabnext tab (Ctrl on every platform)
prev-tabctrl+shift+tabprevious tab
activate-tab-1 … -9mod+1 … mod+9jump to tab N
new-shell-heremod+tnew shell in the focused session’s directory
new-claude-session-heremod+alt+tnew Claude session in that directory
reopen-closed-tabmod+shift+treopen the tab you just closed
close-focusedmod+wclose 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

ActionDefaultDoes
split-verticalmod+dsplit side by side
split-horizontalmod+shift+dsplit stacked
focus-leftmod+shift+leftfocus the pane to the left
focus-rightmod+shift+right… to the right
focus-upmod+shift+up… above
focus-downmod+shift+down… below
focus-next(unbound)cycle forward through panes
focus-prev(unbound)cycle backward

Terminal

ActionDefault (macOS)Default (Windows / Linux)
copycmd+cctrl+shift+c
pastecmd+vctrl+v, ctrl+shift+v
scroll-topcmd+upctrl+up
scroll-bottomcmd+downctrl+down
zoom-incmd+=, cmd+plus, cmd+shift+plusctrl+… (same three)
zoom-outcmd+-ctrl+-
zoom-resetcmd+0ctrl+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

ActionDefaultDoes
focus-searchmod+ffocus the sidebar search box
toggle-sidebarmod+bshow / hide the sidebar
capturemod+shift+swrite a state dump + screenshot
toggle-recordmod+shift+rstart / stop a GIF screencast

Not in the keymap

GestureDoes
Ctrl+Cinterrupt (SIGINT) — passed through to the program
Escapecancel an open prompt, rename or doc pane
Enterconfirm an open prompt
Drag a selectionselect; copies too with terminal.copy_on_select (off by default)
Right-clickpaste, with terminal.paste_on_right_click (off by default)
Wheelscroll back through history, or the wheel event to a program reading the mouse
Click, drag, right-click in a program reading the mousethe event goes to the program — vim, lazygit, Claude Code’s /resume; nothing is selected or pasted locally
Shift+drag in such a programthe terminal’s own selection, as a plain drag is at a shell
Cmd/Ctrl+clickopen a URL, hidden (OSC 8) hyperlink or file path under the pointer
Drag a tabreorder 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" }
ValueBehaviour
alwaysConfirmalways ask
confirmWhenActiveask only while a session runs a foreground process
noConfirmationnever 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…"]
  }
}
KeyDefaultNotes
font_size14pixels, clamped to 6–40. The zoom chords step from here at runtime without rewriting it.
copy_on_selectfalsea drag release or a double-click copies the selection outright, no chord needed
paste_on_right_clickfalsea right-click pastes into the pane under the pointer, not necessarily the focused one
colors.schemebuilt-insolarized-dark, solarized-light, gruvbox-dark, gruvbox-light
colors.foreground / .background / .cursorfrom the scheme"#rrggbb"; the # is optional
colors.palettefrom the schemethe 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": { "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.

RuleWhy
Give a GUI editorthe 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 shellthe 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 lookupthe program is spawned directly; write the full path. An app launched from Finder/Explorer inherits a minimal PATH that often lacks code
Windows: code.cmda direct spawn only ever appends .exe, so a bare "code" is not found
A placeholder in the program name is refusedwhat 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 bridgeThe stdio server
Reachesthe running workspacethe settings file
Setupnone — wired in at spawnyou register termherd-mcp yourself
Available tosessions launched from TermHerdany Claude session
Transportin-process, loopback, per-session bearer tokenJSON-RPC over stdio
Surface17 tools2 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

ToolArgsReturns
list_sessions—{ sessions: [...] } — each row a live session: stable handle, tab title, cwd, kind (shell / claude), resumed Claude id, status
snapshotsections, terminals, focused_terminal, text_linesthe whole state: config, sidebar, tabs and panes
read_terminalsession, lines{ text, rendered }
screenshotmax_widththe 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

ToolArgsNotes
open_sessionproject, kindkind is "shell" (default) or "claude"; omit project for the home dir
split_panedirection, pane"vertical" (default) or "horizontal"; omit pane for the focused one
focus_panesession
rename_tabtab, titletab is the 0-based index snapshot reports; a blank title reverts to the derived one
close_panepanea lone pane is its whole tab, which closes
run_in_sessionsession, textinclude a trailing newline to submit
mouse_in_sessionsession, kind, col, row, buttona mouse event at a cell of the terminal; see below
add_repopathput a repository in the sidebar before it has any session
forget_repopathdrop 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:

FieldMeans
repo_paththe normalised key the row is filed under
declaredwhether it is currently a hand-added repository
session_countsessions on that row right now
in_sidebarwhether 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.

ArgValues
kindpress, release, click, drag, move
col, row0-based cells of the visible screen
buttonleft (default), middle, right

The answer adds a pointer field saying what the terminal did:

pointerMeans
forwardedthe program in the session reads the mouse and was sent the event
selectionno program reads the mouse; the event drove the terminal’s own text selection
ignoredit 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

ToolArgsReturns
wait_for_statussession, statuses, timeout_ms{ status, timed_out }
prompt_in_sessionsession, 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_params error 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:

GapIssue
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.

ToolTakesTests
press_keyschords in settings.json syntax — "cmd+shift+s", "ctrl+tab", "escape"the binding, resolved through the live keymap, including your overrides
run_actionkebab-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

OutcomeMeansExtra field
ranthe ladder applied itaction — the name that ran
inertnothing happenedreason — see below
overlayan open prompt consumed itwhich prompt
typedbound to nothing, so it reached the focused terminal
unboundnothing 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:

ReasonMeansDo
no-surfacethe action is wired to nothing yet (open-new-session is the one)stop — retrying is pointless
no-contexta precondition was absent — nothing focused to derive a repo from, no closed tab to reopen, nothing to scroll, nothing selected to copycreate 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, enter quits the app — killing every session and the connection you are speaking over.
  • session-rename (the sidebar’s inline ✎ field) does not commit on enter, and neither does tab-rename: both commit through the widget’s own submit, which a synthesised key event never reaches. escape abandons 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

ToolArgsDoes
list_options—lists the configurable options with their current values
set_optionid, valuesets one writable option; the change lands in settings.json and applies on restart

Both speak the option id — a stable, dotted name:

idKindWritableValues
themeenumyesdark, light
shell.programstringnounset means the platform default login shell
shell.argsarrayno
terminal.colors.schemeenumyessolarized-dark, solarized-light, gruvbox-dark, gruvbox-light
terminal.colors.foregroundstringyes"#rrggbb"
terminal.colors.backgroundstringyes"#rrggbb"
terminal.colors.cursorstringyes"#rrggbb"
terminal.colors.palettearrayyesthe 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

URIHolds
termherd://options/schemathe schema of the configurable options
termherd://keys/schemathe 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)
CrateIsDepends on
corethe domain: headless App state machine, the tab/pane tree, the keymap, the port traitsclaude only
claudea pure codec for the Claude CLI’s own formats — path encode/derive, JSONL digest, OSC decodenothing
appthe iced GUI shell; builds the adapters in main() and injects them; owns the one effect executor and the MCP control surfacecore + adapters
scanfilesystem discovery — walks ~/.claude/projectscore
ptythe terminal adapter (portable-pty + alacritty_terminal)core
mcpthe stdio settings servercore

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, expect and panic are clippy-denied in core and claude. Production paths return typed errors.
  • unsafe_code is denied workspace-wide. The one sanctioned exception is the macOS AppKit FFI for the Cmd+Q quit path: a cfg-gated module with a SAFETY: note on every block.
  • No global mutable state — no static mut, no lazy_static, no require-time singletons. Dependencies are built in main() and injected.
  • One logging stack: tracing. No println! 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:

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:

LayerOwns
.roadmap/ + docs/PRD.mdthe what and why — features, MoSCoW bucket, shipped history
GitHub issuesthe unit of work — scoped, actionable, typed and labelled
The project boardpriority 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.