Processes are disposable. Sessions are precious.
A session-native terminal multiplexer for AI agent CLIs (pi, Claude Code, codex, gemini, opencode, shell) — no daemon, ever.
- Workspace resurrection. Quit roost, reboot the Mac, run
roostagain — every tab, split, and stacked pane comes back, each agent resumed into its exact session. - Session-native, not process-native. Agent CLIs persist their own conversation state and resume by id, so roost never needs a daemon — it just remembers the layout tree plus each pane's
(adapter, cwd, session-id). - Fleet at a glance. The tab bar, corner badges, and collapsed stack rows show every agent's state — working, needs input, waiting, idle, exited — and roost rings the bell the moment one needs you.
- A control CLI for orchestrators.
roost spawn / send / read / status / wait / close— an LLM (or you) can drive a fleet of agent panes and watch the whole thing live. - ~1.4 MB, no daemon. One binary, zero config, nothing to keep alive in the background.
Full design rationale: DESIGN.md.
roost has no daemon and no detach. When you quit (Alt+q) or close the terminal, roost stops immediately — it does not reattach. What survives is the layout tree and each pane's (adapter, cwd, session-id), stored in workspace.json. Next time you run roost, every tab and split comes back, and agents resume their conversations by session id. What does not survive is an in-flight turn: an agent mid-turn when roost exits loses that turn, though its session state persists for the next resume.
This is deliberate — no daemon means nothing leaks, nothing to restart, and roost never runs invisible in the background. SSH is the one case where this matters: when an SSH session dies, roost dies with it. The fix is to run roost inside tmux or screen on the remote:
ssh user@host
screen # or: tmux
roostThen roost's local (layout, session-id) persistence works normally, and the outer screen/tmux session survives the SSH reconnect. When you reconnect, reattach to screen/tmux, and roost resumes from where it left off.
Needs a Rust toolchain — grab one at rustup.rs if you don't have one.
git clone https://github.com/navbytes/roost
cd roost
cargo build --release
./target/release/roostOr skip the build step with cargo run.
- mise —
mise use -g github:navbytes/roost. Prebuilt binary, no Rust toolchain. See below. brew install navbytes/tap/roost— from thenavbytes/homebrew-taptap. Same prebuilt binaries, checksum-verified by Homebrew.- Prebuilt binaries from
GitHub Releases — macOS and
Linux, arm64 and x86_64, with
SHA256SUMS.txtto check them against. cargo binstall --git https://github.com/navbytes/roost roost— fetches the release binary instead of compiling. The--gitis not optional: the crates.io nameroostbelongs to an unrelated 2018 crate, so a barecargo binstall roostinstalls something else entirely.cargo install --git https://github.com/navbytes/roost— build from source onto your$PATH, no clone needed.
roost isn't in mise's registry and doesn't need to be — name the backend and mise installs it straight from this repo.
The short way — a prebuilt binary from the latest release, no Rust toolchain needed:
mise use -g github:navbytes/roostOr build from source, if you'd rather (needs a Rust toolchain):
mise use -g "cargo:https://github.com/navbytes/roost@branch:main"Pin a version by appending it, e.g. github:navbytes/roost@0.1.20, or swap
@branch:main for @tag:v0.1.20 on the cargo backend. Drop -g to pin roost
per-project in that directory's mise.toml instead of globally.
Point ROOST_STATE somewhere else and you get an independent roost — its own
workspace.json, its own config.json, and its own control socket, so
roost list from that shell talks to that instance and not your main one:
$ ROOST_STATE=/tmp/roost-scratch roostNothing to create first — roost makes the directory. Useful for a throwaway
fleet you don't want mixed into your real workspace, a per-project set of
panes you keep separate, or trying a config.json remap without touching
your own. Delete the directory to throw the whole thing away.
ROOST_STATE is also the root named workspaces live under, and the two
compose — Workspaces covers that. For the raw-variable route,
the rule stands: only one roost runs per state dir at a time — a second
instance on the same dir refuses to start, since they'd race and corrupt
workspace.json.
To run several roosts at once, give each one a name instead of a raw directory. A workspace is a named, independent fleet under the shared state root — its own layout, its own control socket, its own session claims — and exactly one roost runs in it at a time:
$ roost -w tripto # also --workspace tripto, or ROOST_WORKSPACE=tripto
$ roost ws # list every workspace: running/idle, tabs, panes, adapters, last saved
$ roost ws ls --json # the same fields as JSON
$ roost ws mv scratch tripto # rename an idle workspace
$ roost ws rm scratch # delete an idle workspace (its agent sessions are untouched)- One window per workspace. A second
roost -w triptois refused with the name of the workspace and a pointer at-wandroost ws ls. There is no in-TUI switcher: a switch is quit plus relaunch, and the quit guard still confirms while an agent is working. - Names are 1–32 characters of
a-z,0-9,.,_,-(lowercase only, so names never collide on case-insensitive filesystems). Creation is implicit: a new name starts fresh on first use.defaultis the unnamed workspace roost opens with no flag — it can be listed but never renamed or deleted. ROOST_STATEstays the root override — the two compose:ROOST_STATE=/tmp/r roost -w aputs workspaceaunder/tmp/r, andROOST_STATE=/tmp/r roost ws lslists it. PlainROOST_STATEisolation (above) keeps its exact old behaviour, including per-directory config.- Keybindings are shared. A named workspace reads whatever the default
workspace would (below: the root
config.jsonor the~/.config/roost/one, whichever is live); if the workspace directory has its ownconfig.json, that file replaces it wholesale for that workspace (never merged). - Targeting from a plain shell. Out-of-pane verbs take
-wanywhere on the line, before or after the verb (roost -w b list,roost close 3 -w b) — except insidesend's own text, where-wis data, not a flag, past its PANE argument (roost send 3 -w b hisends the literal text "-w b hi" to pane 3); inside a pane your own instance is targeted automatically; the flag beatsROOST_WORKSPACE, which beats the default. When nothing answers, the error names the workspace it tried and lists the ones that are running. - One agent session, one driver. Every running instance holds an exclusive claim per agent session it drives, so two windows can never resume or adopt the same conversation — the second one shows a placeholder naming the workspace that holds it, and resumes once that one quits.
- Identity. Panes get
$ROOST_WORKSPACE; the terminal title readsroost · <workspace> · <pane>for named workspaces (roost · <pane>for the default);roost statusreports the workspace; desktop notifications carry its name. On-screen chrome is unchanged.
It isolates workspace state only. It still installs and updates the pi
extension and the Claude Code hooks in your real ~/.pi and ~/.claude
(below); add ROOST_NO_EXT_INSTALL=1 if you don't want that too. Deliberate:
ROOST_STATE doesn't imply "don't touch my global config" because two
concurrent real roost fleets, each in its own state dir, both legitimately
want the one real ~/.claude wired up.
State lives in ~/.local/state/roost/workspace.json on Linux and
~/Library/Application Support/roost/workspace.json on macOS (auto-saved on
every change, atomic writes) — alongside the control socket, token and audit
log. Delete it to start clean.
Config is the other half and goes somewhere else, because it is the half
you write by hand: config.json lives in ~/.config/roost/ (on macOS,
~/Library/Application Support/roost/). roost still reads an older one from
the state dir beside workspace.json, so nothing breaks if you already have
one there — but ~/.config is where a new one belongs, and roost keys
prints the exact path it uses. Under ROOST_STATE the search stops in that
directory, by design — you named it, so there is nothing to report. Named
workspaces share the root config; a config.json inside a
workspace's own directory replaces it wholesale for that workspace.
roost has no flags for any of this — the whole outside-the-TUI surface is five variables, set on the command that launches it:
| Variable | Effect |
|---|---|
ROOST_STATE=DIR |
use DIR as the state dir: an independent workspace, config and control socket (above). Created if missing |
ROOST_WORKSPACE=NAME |
launch directly into the named workspace instead of default (same as -w NAME; see Workspaces) |
ROOST_NO_EXT_INSTALL=1 |
don't install or update the pi extension and Claude Code hooks in ~/.pi / ~/.claude (below) |
ROOST_NO_QOS=1 |
don't raise input-thread scheduling priority on macOS (below) |
ROOST_DEBUG=1 |
append control-plane diagnostics — dropped socket lines, shed connections — to <state>/roost.log. Any value enables it; the file is written only when there is something to say, and never to the TUI's own output |
Inside every pane roost exports four more, so an agent can call back into the
fleet or know where it's running — $ROOST_SOCK (the control socket),
$ROOST_PANE (which pane it is), $ROOST_TOKEN (authenticates as that
pane) and $ROOST_WORKSPACE (which workspace it's in, default for the
unnamed one). You don't set these; Controlling roost
covers what they're for.
On macOS roost promotes its own input threads to interactive scheduling
priority so typing stays crisp while agent panes saturate the CPU (the
agents themselves keep normal priority); set ROOST_NO_QOS=1 to switch
that off. Either way roost keeps a tiny local latency log — one aggregate
line a minute of event-loop scheduling stalls into <state>/perf.jsonl
(size-capped, timings and counters only, nothing sensitive) — so the
promotion's real-world effect on your machine is decidable from data.
roost's shortcuts all live on Alt. On macOS, Option sends accented
characters by default, so shortcuts silently do nothing until you tell your
terminal to treat Option as Meta/Alt:
- Terminal.app: Settings → Profiles → Keyboard → check Use Option as Meta key.
- iTerm2: Settings → Profiles → Keys → set the Left/Right Option key to Esc+.
- Ghostty / WezTerm / kitty: send Alt by default — nothing to change.
If your first Alt+n seems to do nothing, this is almost certainly why. Once
it's set, Alt+Enter opens the quick-launch picker (pi / claude / shell) and
you're in.
Every shortcut lives on Alt. These twelve get you through a session:
| Key | Action |
|---|---|
Alt+n |
new shell pane (auto split direction) |
Alt+Enter |
quick-launch picker: pi, claude, codex, gemini, opencode, shell |
Alt+arrow / Alt+hjkl |
move focus |
Alt+w |
close pane (twice to confirm when the agent is busy) |
Alt+t, Alt+1..9 |
new tab / go to tab |
Alt+m |
next tab (wraps — the route past the ninth) |
Alt+z |
zoom the focused pane to fill the screen |
Alt+s |
stack the surrounding split — press again to absorb the next one out |
Alt+a |
jump to the next pane that needs input, across tabs |
Alt+c |
copy mode (v marks, y yanks) |
Alt+? |
the full keymap — type to filter, ↵ runs the row |
Alt+q |
quit — workspace saved; agents die, sessions live |
→ Full keybinding reference — every chord roost
binds, the config.json escape hatch for moving or switching any of them off
(including handing Alt+arrow back to your shell), roost keys, mouse and
text selection, and the Shift+Enter story.
You do not have to learn the rest from a page: Alt+? draws the whole table
live, filtered as you type, and shows your bindings rather than the
defaults. A shortcut hint bar along the bottom (zellij-style) shows what you
can press right now and changes with context; Alt+/ hides it to reclaim the
row.
Ten keyboard-first additions for running more agents at once, plus one CLI-only escape hatch — all Alt-only, same layer as everything above.
- Jump to attention (
Alt+a). Jumps to the next pane whose status is ◆ needs-input, across tabs, wrapping back to the first; press again for the next one. If nothing needs input, it falls back to any pane that's simply finished its turn (○ waiting) instead — the hint bar's right segment always matches what the key will actually do:◆ N needs you · Alt+awhen a real ◆ exists,○ N your turn · Alt+afor the fallback, omitted only when there's truly nothing to jump to. Nothing needs you? A flash says so and nothing else changes. - Fleet roster (
Alt+Shift+a). The shifted sibling of the chord above:Alt+atakes you to the next pane that needs you,Alt+Shift+ashows you all of them and lets you choose. A modal list of every pane in the workspace, grouped by tab — id, name, adapter and current state per row, the same way collapsed stack rows read — so the panes sitting in tabs you aren't looking at finally have a resting surface with names on it. It opens with its cursor already on the paneAlt+awould have jumped to, soAlt+Shift+athenEnteris exactlyAlt+a. Arrows andPgUp/PgDnmove, typing filters by id / name / adapter,Entergoes there (across tabs, expanding stacks, revealing the float, like every other jump),Escor the same chord closes it. Clicking a row goes there too. Honest scope: going to a pane is the only thing it does — it's a navigator, not a control panel;roost send <id>is where scripted dispatch lives. - Activity feed (
Alt+e). A modal overlay, not a persistent pane — there's no spare row at an 80×24 terminal — streaming the most recent 200 events: status changes, spawns, closes/reopens, exits, and control-plane calls (soroost send --alland every other control verb show up here too). Session-only, never written to disk.↑/↓scroll,Esc/q/Alt+eclose. - Pane zoom (
Alt+z). A pure view transform: the focused pane fills the screen, but the split/stack tree underneath is untouched. Zoom follows focus inside the tab; switching tabs, closing the zoomed pane, or any layout edit (new pane, split, stack,Alt+g) exits it first, so the layout never changes invisibly underneath you. - Floating scratch pane (
Alt+Shift+z). One app-wide floating shell, toggled in and out of view — the process keeps running while it's hidden. Moving focus away hides it automatically;Alt+wwhile it's focused kills it for real. Honest scope: it's session-only, never written toworkspace.json, and gone at quit like any other unsaved state — there's no persistent fourth pane type here, just an ephemeral one. - Raw pass-through (
Alt+Shift+p). Marks the focused pane raw: every key except the toggle itself — including every otherAltchord — forwards straight through as bytes, so an agent CLI with its own Alt bindings (readline word-ops, custom editors) sees a normal terminal. The hint bar showsAlt+Shift+p exit rawthe whole time you're in it, so you can't get stuck; the same chord that gets you in gets you out. - Keyboard copy mode.
Alt+cnow also takeshjkl/arrows to move a cursor,0/$for line start/end,vto mark an anchor, andy/Enterto yank — the mouse-drag path from before still works, and the two interleave freely (a drag moves the keyboard cursor too);Esc/qexits and clears the selection. Honest scope: visible grid only, same as the mouse path — no scrollback paging inside copy mode. - Canned layouts (
Alt+g). Cycles the active tab through three built-in arrangements — even grid, main pane + stack, all-stack — skipping any that wouldn't fit the terminal, and always keeping focus and pane order stable. It's a snap-to-arrangement, not undoable viaAlt+u. - Tab undo, the honest scope.
Alt+ualready reopened panes; it now reopens whole tabs the same way — name, layout, and pane specs restored, session ids included, so agents resume where they left off. The one limit worth knowing: a multi-pane tab is dismantled close-by-close, so undoing one that had several panes replays them as individual pane-undos — sessions intact, but re-split off the focused pane rather than at their original geometry. The undo stack holds the last 20 closes and is session-only (cleared on quit). - Pane notes, inside
Alt+r. The edit dialog holds the pane's whole text surface — the name on its underlined first row, and under it a parking note: where this pane stands and what's next, written before you close the lid, read the next morning without a ritual. The note's first line shows in the focused pane's corner badge with an age tag (14h,2d— a stale note confesses instead of lying), and every other pane that carries one shows a bare¶(¶⋮when there's more under the first line), so walking your tabs and panes reveals each note where you're already looking. Nothing is ever shown all at once.Shift+Entermoves from name to note and adds lines (up to 8; multi-line pastes work too);Entersaves both fields — the age re-stamps only when the note actually changed, so renaming can't make a stale note look fresh — and emptying the note (Ctrl+U+Enter) clears it ("handled") without touching the name. Notes live inworkspace.jsonnext to the session id, so they survive quits and reboots, andAlt+urestores a note with its closed pane. No chord to learn:Alt+rwas already the rename key, and it still is. - Broadcast.
roost send --all TEXT [--enter]types into every running pane at once — CLI/control-plane only, deliberately no TUI key so a fat-fingeredAltchord can't blast the whole fleet. See Controlling roost below.
Tab bar, corner badges, and collapsed stack rows all show the same states:
| Glyph | Meaning |
|---|---|
⠋⠙⠹… |
working — spins (pi's own loader frames; animation always means busy, never "look at me") |
◆ |
needs input |
○ |
waiting for you |
· |
idle |
✕ |
exited |
Status lives in the glyph, not the border — the focused pane's border is always accent-red, everything else stays quiet. When a non-focused pane starts waiting for you, roost rings the terminal bell (and posts a native notification on macOS).
A tab's glyph summarizes its panes (worst first), and carries a count
when more than one pane is in that state — ◆3 is three agents waiting on
you in that tab, ⠋2 two working. Ten or more shows +. The count cell is
always reserved (blank below two), so tab widths never shift as statuses
change. For the names behind the number, Alt+Shift+a lists every pane.
Status arrives two ways:
- Exact — agent-side integrations report over roost's unix socket
(
$ROOST_SOCK, pane identified by$ROOST_PANE, authenticated with a per-pane$ROOST_TOKEN):- pi:
extensions/roost.ts— roost installs/updates it into~/.pi/agent/extensions/automatically at startup when pi is present (setROOST_NO_EXT_INSTALLto manage it yourself). Uses pi'sagent_start/agent_settled/session_start/project_trust/ask-tool events (pi ≥ 0.80.4), reports session ids instantly — and when an ask-tool fires, sends the question itself, so the feed line and notification say what's being asked. A turn that ends on a prose question ("Which database should I use?") is reported as needs-you too, question attached — heuristic (trailing?on the final line), self-healing, and self-explanatory since the text rides along. - Claude Code: roost installs/updates three hooks into
~/.claude/settings.jsonautomatically at startup when Claude Code is present (sameROOST_NO_EXT_INSTALLopt-out). The needs-you hook forwards Claude's own reason ("Claude needs your permission to use Bash") the same way. Details:extensions/claude-code-hooks.md.
- pi:
- Heuristic fallback — recent PTY output ⇒ working; silence ⇒ waiting; a
terminal bell (
0x07) ⇒ needs-you (tmux-style); and for agents that publish state in their terminal title (Claude Code's braille spinner while working,✳at rest) the title itself — an exact-ish signal that fills the gap between Claude's one-shot hook connections, never overriding a live extension link or a needs-you. pi needs none of this for its own needs-you signal: its one blocking prompt (the project-trust dialog) is reported directly via theproject_trustevent above.
Each pane also carries a faint corner badge, top-right (iTerm2-style):
name · adapter glyph — the name is its Alt+r title, or the adapter name
(pi, claude, codex, gemini, opencode, or shell) when unnamed, and the glyph is the pane's live
status. A cell TUI can't do true translucency, so it's rendered dim rather
than see-through; the inner app's content still draws underneath it. A pane
with an Alt+r note adds a ¶ here — and on the focused pane, the
note's first line and age, full-strength: the one thing in the badge that
isn't dim.
roost's chrome inherits your terminal's theme; its text is exactly as legible as your shell prompt.
roost's own chrome — tabs, borders, badges, hint bar — is drawn in your colors. Every word it puts on screen uses your terminal's own foreground on your own background, the one contrast pair you have already proved readable, optionally one step quieter. The one accent is your ANSI red. Borders and separators are the only thing spending ANSI 8, so a theme that renders it faint costs you a hairline, not a word. Surfaces that need to shout (the flash, the dead-pane bar, the Alt-key warning) reverse your fg/bg rather than painting a color, and roost paints no background fill anywhere at all.
That means light themes, dark themes, tinted themes and solarized-anything all
work, there is nothing to configure, and switching your terminal's theme while
roost is running just works — nothing is detected, cached, or assumed. Program
output inside panes is untouched: it keeps its own colors and attributes,
truecolor included. Full design spec: DESIGN-ui.md.
| Adapter | Launch | Resume | Session detection |
|---|---|---|---|
pi |
pi |
pi --session <id> |
socket handshake, or newest file under ~/.pi/agent/sessions/ |
claude |
claude |
claude --resume <id> |
newest *.jsonl under ~/.claude/projects/<encoded-cwd>/ |
shell |
$SHELL |
relaunch in saved cwd | — |
codex |
codex |
codex resume <SESSION_ID> |
newest *.jsonl under ~/.codex/sessions/YYYY/MM/DD/ (date-bucketed, not cwd-bucketed — detection cannot be scoped to a working directory) |
gemini |
gemini |
gemini --resume <uuid> |
per-project history under ~/.gemini/tmp/<slug>/chats/, slug read from ~/.gemini/projects.json; session id extracted from file's first JSONL record |
opencode |
opencode |
opencode --session <id> |
global SQLite database at $XDG_DATA_HOME/opencode/opencode.db (no filesystem detection — resume only by stored id) |
New adapters implement the AgentAdapter trait in src/agents/ (eight
methods, most with defaults).
A running roost can be driven programmatically over its control socket — the same binary in client mode:
roost list # panes: id, adapter, cwd, status, …
roost spawn pi --cwd ~/api --input "run the tests, report pass/fail"
roost read 5 # a pane's current screen (default)
roost read 5 --tail 20 # its last N lines (--full for the whole scrollback)
roost send 5 hello world --enter # type into a pane (+ Enter)
roost send --all "standup: reply with status" --enter # broadcast to every reachable pane
roost status 5 # working | waiting | needs_input | …
roost wait 5 --until waiting --timeout 300 # block until the agent finishes
roost fork 5 # a sibling in the same context
roost close 5 [--force]
roost focus 5 # focus a pane: switch to its tab, land focus on it(roost --help prints the same verb set, in a different order.)
Exit codes: 0 ok · 1 runtime error · 2 usage error · 3 wait timed out. The wait timeout is a distinct code so scripts can branch on it — wait && read needs to know the difference between a timeout and success.
wait is what turns "spawn then poll" into "spawn → await → read": block until a
pane hits a status (or a timeout), so an orchestrator doesn't sleep-and-grep.
When you spawn with initial_input, roost holds that input and delivers it after
the pane's first PTY output, guaranteeing the CLI is alive and reading before a
prompt lands — no silent loss like a script racing a not-yet-ready agent.
send --all is send's broadcast form, not a separate verb — it fans out to
every running pane the caller may target, except the floating scratch shell:
every spawned pane for the fleet token (float excluded), or just its own spawned
subtree for a pane acting via its own $ROOST_TOKEN. Non-running panes are skipped, not errors;
the reply reports which pane ids it reached. Audited like every control
action, by shape only — broadcast len=<n> submit=<bool> -> ok count=<n> —
the message text itself is never written to control.log.
This is how an LLM manages a fleet — an agent inside a pane can spawn and drive worker panes for its sub-agents, and you watch (and take over) the whole fleet live. See DESIGN-control.md.
Authorization is scoped by default, not sandboxed. A pane acting via its own
$ROOST_TOKEN authenticates as that pane: its spawn/fork calls are scoped
to the subtree it creates, and its actions are audited under that pane's id.
That's convenience and defense-in-depth, not a hard security boundary — pi
and Claude Code both ship a shell/exec tool, so any in-pane agent can cat the
fleet token at <state>/control.token (0600, never placed in a pane's env, but
readable by anything running as you) and drive the whole fleet with full reach.
Treat every in-pane agent as capable of full control-plane access. The boundary
roost does enforce is cross-UID: the socket and control.token are 0600
inside an owner-verified 0700 state dir, so no other user on the machine can
drive your roost. Targeting is daemonless: an in-pane client finds its instance
via $ROOST_SOCK automatically. Every control action is recorded in
<state>/control.log (principal, verb, target, outcome — never the message
text).
Architecture (ports & adapters)
The core never touches a PTY, socket, or the filesystem — it talks to traits
in src/ports.rs, and every core behavior is unit-tested against in-memory
fakes. Real I/O lives at the edges:
src/
core/ the domain — pure, fully unit-tested
layout.rs split/stack/pane tree, ops, geometry
workspace.rs tabs + (adapter, cwd, session-id) per pane
status.rs Working/NeedsInput/Waiting/Idle/Exited model
app.rs orchestration: App<B: PaneBackend>, actions, modes
event.rs event vocabulary (PTY output, exit, socket events)
ports.rs trait boundaries: PaneBackend, StateStore, Notifier
(+ fakes for tests: FakePane, MemStore, RecordingNotifier)
agents/ domain adapters per CLI: pi, claude, shell (AgentAdapter)
infra/ production port implementations — all real I/O
pty.rs PaneBackend via portable-pty + vt100
store.rs StateStore via atomic workspace.json writes
sock.rs status socket listener (ndjson over unix socket)
notify.rs Notifier via terminal bell / macOS osascript
ui/ presentation
render.rs ratatui drawing (generic over PaneBackend)
input.rs key → Action/bytes translation (pure)
mouse.rs hit-testing + wheel routing decisions (pure)
main.rs composition root: wires infra into core, runs the loop
vendor/vt100/ vendored vt100 with a scrollback-underflow fix (see below)
vendor/vt100: a fork of upstream vt100 0.15.2 carrying roost's SPEC-parity
patch set — synchronized output (P1), live reflow (P5), focus reporting
(P10), dim/strikethrough (P16), unicode-width parity (P17), REP (P19),
stream effects (W3), OSC 9/52/777, DECSCUSR, and assorted hardening
(including a saturating-subtraction fix for the visible_rows()
scrollback-underflow panic upstream hits when scrolled back further than one
screen height).
M0 render core ✓ · M1 splits/tabs ✓ · M2 persistence + session detection ✓ ·
M3 status socket + badges ✓ · M4 stacks + resize ✓ · M5 picker, rename,
scroll, notifications ✓ · fleet features (jump, feed, zoom, float, raw mode,
keyboard copy, layouts, broadcast) ✓ · mouse, wheel routing and link-opening ✓ ·
native mouse selection ✓ · a drag holds the view still so you copy what you
highlighted ✓ · adapters for pi, Claude Code, shell, codex, gemini and
opencode ✓ · a small config.json to remap or disable individual keys ✓.
What's left is deferred scope and deliberate choices, no known-broken
defects — full detail: ROADMAP.md.
DESIGN-ui.md (design spec) · ROADMAP.md · LICENSE