muxr

A keyboard-driven terminal multiplexer in pure Ruby. muxr (Ruby + Unix) combines the familiar keybindings of GNU Screen, the automatic tiling of xmonad, and a Quake-style drop-down drawer. Panes are treated like tiling-window-manager clients — you never resize them by hand; the active layout decides geometry.

┌─ #1 a3f9b2 ★ · npm test ──── [NORMAL] ─┬─ #2 c2e810 ──────────────┐
│ master pane (running npm test)         │ stacked slave pane       │
│                                        │                          │
│                                        ├──────────────────────────┤
│                                        │ #3 9b1d04 [P]            │
│                                        │ private pane (MCP-hidden)│
└────────────────────────────────────────┴──────────────────────────┘
┌ Drawer ────────────────────────────────────────────────────────────┐
│ persistent overlay shell, opens from the bottom                    │
└────────────────────────────────────────────────────────────────────┘
 [NORMAL] [default] panes:3 layout:tall focused:#1 drawer:shown   muxr ^a ?

Each pane shows its slot (#1, #2, …) plus a stable 6-hex id (a3f9b2). The slot is positional and shifts when panes are created, killed, or promoted; the id is generated once and survives layout changes, detach/reattach, and cold-restart from the session JSON. [P] marks a private pane that the MCP control surface refuses to read or drive (see MCP control surface below). The focused pane's title shows the foreground command running in its PTY (e.g. · npm test) when something other than the shell is in the foreground, and the [NORMAL] chip in the top-right corner — along with the border color — tracks the current input mode.

Screenshots

The three built-in layouts (pick directly with t/g/m in normal mode, or cycle with Tab / C-a Tab):

tall
master + stacked slaves
grid
even tiling
monocle
focused pane fullscreen
tall layout grid layout monocle layout

The Quake-style drawer overlay (~ in normal mode, C-a ~ in passthrough):

drawer overlay

Install / run

gem install muxr
muxr                     # attach the "default" session (auto-spawn if needed)
muxr work                # attach (or start) a named session
muxr --list              # list running sessions and exit
muxr --install-skill     # install the MCP skill into ~/.claude/skills
muxr --help

Requires Ruby ≥ 3.4. No runtime gems — just PTY, IO.console, JSON, Socket, and FileUtils from stdlib.

muxr is the client. The first invocation for a session daemonizes a server in the background; subsequent invocations attach to it over a Unix socket. d (normal mode) / C-a d (passthrough) detaches the client and leaves the server (and every shell it owns) running, so reattaching gives you back the exact same panes with their full history.

From source

To run the latest unreleased code or hack on muxr locally, clone the repo and use bin/muxr directly — it puts lib/ on $LOAD_PATH itself:

git clone https://github.com/roelbondoc/muxr
cd muxr
bin/muxr                 # same flags as the installed `muxr` executable

Modes

muxr has two top-level input modes, modeled on vim:

  • Normal (default at startup) — single keys act on the multiplexer. hjkl moves focus between panes, c/K create/kill panes, t/g/m set the layout, etc. No prefix needed.
  • Passthrough (entered with i) — every keystroke is forwarded to the focused pane, exactly like a regular terminal. muxr commands are reached via the historical Ctrl-a prefix. Ctrl-a Esc returns to normal mode.

The active mode appears as a [MODE] chip in the top-right corner of the focused pane (and the leftmost slot of the status bar). The focused pane's border is colored by mode — cyan for normal, green for passthrough, orange for scrollback, magenta for selection, yellow for the command prompt, red during the kill-session confirmation, blue while help is open. Unfocused panes always render with the grey unfocused border, regardless of mode.

Normal mode

Keys Action
h / j / k / l focus pane left / down / up / right (spatial)
i drop into passthrough mode
c / K new / close focused pane
t / g / m layout: tall / grid / monocle
Tab / Enter cycle layout / promote focused to master
a / 19 toggle last pane / jump to pane by number
s enter scrollback / copy-mode
~ / C / P drawer / Claude drawer / toggle private flag
] paste internal yank buffer into focused pane
: / ? command prompt / help
d / q detach / kill session (asks y/n)

h/j/k/l does true spatial navigation — it inspects the current layout's rectangles and picks the closest neighbor in the requested direction. In monocle (where every pane occupies the full area) it falls back to linear next/previous so the keys still do something useful.

Passthrough mode (Ctrl-a prefix)

Keys Action
C-a Esc return to normal mode
C-a c new pane
C-a n / p focus next / previous pane (linear)
C-a a toggle last (previously focused) pane
C-a 19 jump to pane by its label
C-a K close focused pane (or hide drawer)
C-a Tab cycle layout (tallgridmonocle)
C-a Enter promote focused pane to master
C-a ~ toggle drawer (shell)
C-a C toggle Claude Code drawer (MCP-aware)
C-a P toggle private flag on focused pane (hides from MCP)
C-a [ enter scrollback / copy-mode
C-a ] paste internal yank buffer into focused pane
C-a d detach (server keeps running)
C-a q kill session (asks kill session? (y/n))
C-a : command prompt
C-a ? help
C-a C-a send literal C-a to focused pane

Scrollback and copy-mode

Each pane keeps a bounded (5000-row) scrollback ring. s in normal mode (or C-a [ in passthrough) enters scrollback with vi-style navigation; the status bar shows a key hint and the pane title gains [scrollback N/M].

Keys Action
j / k scroll one line
d / u (or C-d/C-u) half page
f / Space (or C-f/C-b) full page
g / G top / bottom
q / Esc / C-c exit back to normal mode

Press v inside scrollback to enter a movable-cursor selection mode. Vim-style motions are supported:

Keys Action
h / j / k / l left / down / up / right
0 / ^ / $ line start / first non-blank / line end
w / W next word / WORD start
e / E next word / WORD end
b / B previous word / WORD start
g / G top / bottom of timeline
H / M / L top / middle / bottom of viewport
C-d/C-u, C-f/C-b, Space half / full page
v / C-v anchor char / block selection (toggle)
y or Enter yank and return to normal mode
q / Esc / C-c cancel back to scrollback

v and C-v toggle between character and block (rectangular) selection — switching between the two preserves the anchor. y or Enter yanks the selection into an internal buffer, pipes it to pbcopy in the background (silent no-op when pbcopy is unavailable), and returns to normal mode. ] (normal) / C-a ] (passthrough) writes the yank buffer back into the focused pane.

Commands (typed after : in normal mode, or C-a : in passthrough)

layout {tall|grid|monocle}     # also: layout (no arg) → cycle
drawer {toggle|show|hide|reset}
claude                         # toggle the Claude Code drawer
private                        # toggle private flag on focused pane
save                           # persist session to ~/.muxr/sessions/<name>.json
restore                        # show path to saved session
sessions | ls                  # list saved sessions
new | close | next | prev | master
detach | quit                  # quit asks for y/n confirmation

MCP control surface

muxr exposes a second listener at ~/.muxr/sockets/<name>.ctrl.sock that accepts multiple concurrent NDJSON clients over a small JSON-RPC surface (session.get, panes.list, pane.read, pane.send_input, pane.run, pane.subscribe, pane.kill, layout.set, drawer.*, …). The control socket is independent of TTY attach — programmatic clients never count as "attached", so a Claude Code session and a human can drive the multiplexer concurrently.

pane.run waits for the PTY to go idle before responding: it sends the input, polls for output, and returns once no bytes have arrived for idle_ms (default 500). Server-side idle detection avoids the send-then-poll race that plagues naive client-side automation.

pane.send_input, pane.run, and drawer.send_input accept a keys array of vim-style <name> tokens (<esc>, <c-c>, <cr>, arrows, etc.) interleaved with literal text — callers don't have to remember that Escape is "\e" and Ctrl-C is "\x03". Bracketed-paste wrapping still applies to literal segments only.

Claude Code integration

muxr --install-skill            # copies skills/muxr-control into ~/.claude/skills
                                # and prints the `claude mcp add` registration line

bin/muxr-mcp is the standalone MCP-over-stdio bridge that translates Claude Code tool calls into NDJSON requests on the control socket. It auto-detects the target session from MUXR_CONTROL_SOCKET or MUXR_SESSION env vars.

C (normal) / C-a C (passthrough) / :claude opens a drawer whose shell is claude, with MUXR_SESSION, MUXR_CONTROL_SOCKET, MUXR_FOCUSED_PANE, and MUXR_DRAWER_SELF=1 injected into its environment. The bridge picks those up automatically; you get a Quake-style Claude Code overlay that already knows what session it's in. MUXR_DRAWER_SELF makes the bridge refuse drawer.* methods, so a claude drawer can't recurse into its own PTY.

Private panes

P (normal) / C-a P (passthrough) / :private flips the private flag on the focused pane. Private panes are hidden from programmatic callers: panes.list strips cwd/rows/cols, and pane.read, pane.send_input, pane.run, pane.subscribe, and pane.kill refuse with an error message pointing the human at the TTY (P / C-a P) to expose it. The flag is persisted in session JSON and shown as [P] in the pane title bar. The MCP surface intentionally has no method to flip the flag — only a human at the TTY can mark a pane public again.

Architecture

muxr runs as two processes that talk over a Unix domain socket at ~/.muxr/sockets/<name>.sock. The server owns the PTYs and all session state; the client is a thin TTY front-end that comes and goes across detach/reattach.

Client (foreground, owns the TTY)              Server (daemon, owns the PTYs)
 ├─ STDIN in raw mode + alt screen              Application (event loop, lifecycle)
 ├─ SIGWINCH → RESIZE frame                      ├─ Session ─ Window ─ Pane[ ] ─ Terminal + PTYProcess
 │                                               │      └─ Drawer ─ Pane
 └─ Protocol                                     ├─ Renderer        – diff-emits ANSI as OUTPUT frames
     ◄── OUTPUT bytes ──── Renderer ◄────────────┤   InputHandler    – normal/passthrough mode state machine
     ──── INPUT bytes ───► InputHandler          ├─ CommandDispatcher – parses ":"-prefixed commands
     ──── HELLO/RESIZE ──► apply_size            ├─ LayoutManager    – pure (layout, count, area) → [Rect]
     ◄── BYE ───────────── disconnect_client     ├─ UNIXServer (TTY socket, one client at a time)
                                                 └─ UNIXServer (.ctrl.sock, many NDJSON clients)

Frames are length-prefixed ([1-byte type][4-byte BE length][payload]): H hello, I input, R resize, B bye, O output.

A second listener at ~/.muxr/sockets/<name>.ctrl.sock accepts multiple concurrent NDJSON clients for the MCP control surface (see above). The two sockets are independent — programmatic clients never count as "attached", so they don't lock out the human's TTY client.

The server's event loop is single-threaded IO.select over the listening sockets, the attached client (when present), every pane PTY, the drawer PTY, and every connected control client. A single background thread polls each pane's foreground process group every 750ms (/proc/<pid>/stat on Linux, ps -o tpgid=,pgid= on macOS) so the · cmd annotation in the pane title can refresh without blocking the render loop. Everything else stays on the main thread. Layouts are pure — LayoutManager has no mutable state, so the renderer recomputes geometry on every tick after a resize or pane add/remove without bookkeeping.

d (normal) / C-a d (passthrough) detaches the client but leaves the server (and its shells) running; reattaching gives you back the same panes with their full history. q / C-a q / :quit flash kill session? (y/n) in the status bar and only tear the server down on y — there is no "kill without confirm" keybinding by design.

The drawer's PTY is never torn down when the drawer is hidden — its shell process keeps running so the next toggle restores the previous session. Its initial working directory is inherited from whatever pane was focused when the drawer was first created; only drawer reset kills the PTY.

The per-pane Terminal is a real VT100 emulator (cursor movement, SGR including 256-color/truecolor and underline subparameters, erase/insert/ delete, autowrap, scroll regions). Scrollback is composited into the visible grid through a view-offset that auto-tracks new rows while scrolled back, so reviewed content stays frozen.

Session persistence

Sessions live in ~/.muxr/sessions/<name>.json:

{
  "name": "default",
  "layout": "tall",
  "focused_index": 0,
  "master_index": 0,
  "panes":  [
    {"id": "a3f9b2", "cwd": "/home/me/code", "private": false},
    {"id": "c2e810", "cwd": "/tmp", "private": true}
  ],
  "drawer": {"visible": true, "cwd": "/home/me/code"}
}

Pane ids and the private flag are persisted, so the same ids survive cold-restart from the JSON snapshot and a pane that was marked private stays private.

The JSON file is mainly a cold-storage fallback. Between detaches the live session lives inside the running server process, so d (normal) / C-a d (passthrough) then bin/muxr <name> reattaches to the exact same shells with their full history. The JSON only matters once the server is gone (after q / C-a q or a reboot): re-launching muxr <name> rebuilds pane and drawer shells using the saved working directories. Shell command history within those panes is not persisted — that's the job of your shell's own history file. Run :save from inside muxr to write the snapshot.

Development

bundle install      # only minitest and rake
rake test           # full suite (200+ unit tests)

# Run a single file or test
ruby -Ilib -Itest test/test_layout_manager.rb
ruby -Ilib -Itest test/test_terminal.rb -n test_csi_cursor_position

Tests cover the layout algorithms (including spatial neighbor lookup for hjkl), drawer state machine, window pane ordering, session JSON round-trip, the client/server framing protocol, the input-handler state machine (normal/passthrough mode transitions, scrollback, selection), foreground-command parsing (Linux /proc stat format and shell-filter rules), the renderer's diff-emit, and the VT100 emulator's cursor movement, SGR (including colon-subparameter and underline-color forms), erase, scroll-region, and autowrap handling. PTY-dependent code paths are exercised via dependency injection so tests don't spawn shells.

On-disk layout:

~/.muxr/
 ├─ sessions/<name>.json        structural snapshot written by `:save`
 ├─ sockets/<name>.sock         TTY client listener (auto-managed)
 ├─ sockets/<name>.ctrl.sock    MCP control listener (auto-managed)
 └─ logs/<name>.log             server stdout/stderr

Contributing

Contributions are welcome from anyone, with one requirement: the code must be generated by a frontier LLM (e.g. Claude, GPT, Gemini at their current top-tier model). Hand-written patches will not be accepted.

When you open a PR, please:

  • State which model produced the change in the PR description.
  • Include the prompt(s) you used, or a short summary of the conversation that produced the diff.
  • Drive the model yourself — review, push back, iterate. You are responsible for the patch: it should pass rake test, follow the conventions in CLAUDE.md, and not regress existing behavior.

Bug reports, feature requests, and design discussion in issues are welcome regardless of how they're written.