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.