muxr 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,476 +1,852 @@
1
- # muxr
1
+ <h1 align="center">muxr</h1>
2
+
3
+ <p align="center">
4
+ <strong>A keyboard-driven terminal multiplexer in pure Ruby.</strong><br/>
5
+ GNU Screen's keybindings · xmonad's automatic tiling · a Quake-style drop-down drawer
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://rubygems.org/gems/muxr"><img alt="gem version" src="https://img.shields.io/gem/v/muxr?color=%23c94f4f&label=gem"></a>
10
+ <img alt="ruby 3.4 or newer" src="https://img.shields.io/badge/ruby-%E2%89%A5%203.4-c94f4f">
11
+ <img alt="zero runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-0-4c9a72">
12
+ <a href="LICENSE.txt"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="https://roelbondoc.github.io/muxr/">Project page</a> ·
17
+ <a href="#install">Install</a> ·
18
+ <a href="#layouts">Layouts</a> ·
19
+ <a href="#keybindings">Keybindings</a> ·
20
+ <a href="#configuration">Configuration</a> ·
21
+ <a href="#claude-code-integration">Claude Code</a> ·
22
+ <a href="CHANGELOG.md">Changelog</a>
23
+ </p>
24
+
25
+ ![muxr running a tall layout across five panes](docs/screenshots/hero.png)
26
+
27
+ muxr treats panes the way a tiling window manager treats windows: you never
28
+ drag a divider or resize a split by hand. You say *how many panes* and *which
29
+ layout*, and the geometry follows. Everything else — the VT100 emulator, the
30
+ protocol, the tiling maths — is stdlib Ruby with no runtime gems.
31
+
32
+ ## Highlights
33
+
34
+ | | |
35
+ |---|---|
36
+ | **Ten automatic layouts** | tall, wide, columns, rows, grid, spiral, centered, stack, monocle, auto — each a pure function of pane count and screen size, with an xmonad-style resizable master area and a one-key zoom |
37
+ | **Two input modes** | *normal* acts on the multiplexer with single keys; *passthrough* forwards everything to the shell behind the classic `Ctrl-a` prefix |
38
+ | **Detach and reattach** | the server keeps every PTY alive; reattaching gives you back the same shells with full history |
39
+ | **Quake-style drawer** | a persistent overlay shell that drops from the top of the screen and never loses its scrollback |
40
+ | **Real terminal emulation** | truecolor SGR, scroll regions, alternate screen, bracketed paste, wide/CJK/emoji cells, OSC 8 hyperlinks |
41
+ | **Scrollback with vi motions** | 50,000-row ring per pane, `/` search with smart-case, character and block visual selection, yank to the system clipboard, `:capture` a whole history to a file |
42
+ | **Knows which pane wants you** | a pane that rang the bell, printed while you looked away, or went quiet for longer than you asked is marked in its title and the status bar |
43
+ | **Type into every pane at once** | `:sync` broadcasts keystrokes and pastes across the window, with a red status chip so you never forget it is on |
44
+ | **Panes across sessions** | borrow a live pane from another muxr session, or hand it over for good by passing its pty file descriptor down a socket |
45
+ | **Built for agents** | a JSON-RPC control socket, an MCP bridge, panes you can name and refer to by name, and private panes that programmatic callers cannot see or touch |
46
+ | **Configurable without patching** | `~/.muxr/config.json` sets the default layout, scrollback depth, master shape, the prefix key, and remaps any key onto an action or a `:` command |
47
+
48
+ ## Install
2
49
 
3
- A keyboard-driven terminal multiplexer in pure Ruby. `muxr` (Ruby + Unix)
4
- combines the familiar keybindings of **GNU Screen**, the automatic tiling
5
- of **xmonad**, and a **Quake-style drop-down drawer**. Panes are treated
6
- like tiling-window-manager clients — you never resize them by hand;
7
- the active layout decides geometry.
50
+ ```bash
51
+ gem install muxr
52
+ ```
8
53
 
54
+ Requires **Ruby ≥ 3.4**. There are no runtime gem dependencies — muxr uses
55
+ only `PTY`, `IO.console`, `Socket`, `JSON`, `Zlib`, and `FileUtils` from
56
+ stdlib.
57
+
58
+ ```bash
59
+ muxr # attach the session for the current directory
60
+ muxr work # attach (or start) a session named "work"
61
+ muxr --list # list running sessions and exit
62
+ muxr --install-skill # install the Claude Code skill + MCP bridge
63
+ muxr --help
9
64
  ```
10
- ┌─ #1 a3f9b2 ★ · npm test ──── [NORMAL] ─┬─ #2 c2e810 ──────────────┐
11
- │ master pane (running npm test) │ stacked slave pane │
12
- │ │ │
13
- │ ├──────────────────────────┤
14
- │ │ #3 9b1d04 [P] │
15
- │ │ private pane (MCP-hidden)│
16
- └────────────────────────────────────────┴──────────────────────────┘
17
- ┌ Drawer ────────────────────────────────────────────────────────────┐
18
- │ persistent overlay shell, opens from the bottom │
19
- └────────────────────────────────────────────────────────────────────┘
20
- [NORMAL] [default] panes:3 layout:tall focused:#1 drawer:shown muxr ^a ?
65
+
66
+ `muxr` is the client. The first invocation for a session daemonizes a server
67
+ in the background; later invocations attach to it over a Unix socket. With no
68
+ arguments the session is named after the current directory, so running `muxr`
69
+ in a project always lands you back in that project's session.
70
+
71
+ ### From source
72
+
73
+ ```bash
74
+ git clone https://github.com/roelbondoc/muxr
75
+ cd muxr
76
+ bin/muxr # same flags as the installed executable
77
+ ```
78
+
79
+ `bin/muxr` puts `lib/` on `$LOAD_PATH` itself — no `-I` and no bundler needed
80
+ to run it.
81
+
82
+ ## A 60-second tour
83
+
84
+ Start muxr and you are in **normal mode**, where single keys drive the
85
+ multiplexer:
86
+
21
87
  ```
88
+ c c c three more panes
89
+ t tall layout — master left, the rest stacked right
90
+ l j k move focus around spatially
91
+ J drag the focused pane down past its neighbour
92
+ Enter promote the focused pane to master
93
+ > > give the master a bigger share of the screen
94
+ z zoom the focused pane; z again to put the layout back
95
+ :rename api name the pane so its title says what is in it
96
+ i drop into passthrough and actually use the shell
97
+ C-a Esc back to normal mode
98
+ ~ drop the drawer over everything
99
+ s /error scroll back and search
100
+ d detach; `muxr` again to pick up exactly where you left off
101
+ ```
102
+
103
+ Press `?` at any time for the full keymap:
22
104
 
23
- Each pane shows its slot (`#1`, `#2`, …) plus a stable 6-hex id
24
- (`a3f9b2`). The slot is positional and shifts when panes are created,
25
- killed, or promoted; the id is generated once and survives layout
26
- changes, detach/reattach, and cold-restart from the session JSON. `[P]`
27
- marks a private pane that the MCP control surface refuses to read or
28
- drive (see [MCP control surface](#mcp-control-surface) below). The
29
- focused pane's title shows the foreground command running in its PTY
30
- (e.g. `· npm test`) when something other than the shell is in the
31
- foreground, and the `[NORMAL]` chip in the top-right corner — along
32
- with the border color — tracks the current [input mode](#modes).
105
+ ![the built-in help overlay](docs/screenshots/help.png)
33
106
 
34
- ## Screenshots
107
+ ## Layouts
35
108
 
36
- The built-in layouts (pick directly with the keys below in normal mode, or cycle with `Tab` / `C-a Tab`):
109
+ Layouts are pure functions of `(layout, pane count, area)`. There is no
110
+ per-pane saved geometry to drift out of sync, so adding, closing, or promoting
111
+ a pane simply recomputes the tiling on the next frame.
37
112
 
38
113
  | Layout | Key | Geometry |
39
114
  |--------|-----|----------|
40
- | `tall` | `t` | master on the left, slaves stacked on the right |
41
- | `wide` | `w` | master on top, slaves split across the bottom |
42
- | `columns` | `\|` | equal-width full-height vertical strips |
43
- | `rows` | `-` | equal-height full-width horizontal strips |
44
- | `grid` | `g` | roughly-square even tiling |
45
- | `spiral` | `f` | Fibonacci spiral winding inward (each pane half the last) |
46
- | `centered` | `e` | master in a centred column, slaves dealt to both sides |
47
- | `stack` | `S` | accordion — focused pane expands, others collapse to title slivers |
48
- | `monocle` | `m` | focused pane fullscreen |
115
+ | `tall` | `t` | master on the left, the rest stacked on the right |
116
+ | `wide` | `w` | master on top, the rest split across the bottom |
117
+ | `columns` | `\|` | equal-width, full-height vertical strips |
118
+ | `rows` | `-` | equal-height, full-width horizontal strips |
119
+ | `grid` | `g` | roughly-square even tiling |
120
+ | `spiral` | `f` | Fibonacci spiral winding inward — each pane half the last |
121
+ | `centered` | `e` | master in a centred column, the rest dealt to both sides |
122
+ | `stack` | `S` | accordion — the focused pane expands, others collapse to title slivers |
123
+ | `monocle` | `m` | focused pane fullscreen |
124
+ | `auto` | `F` | `spiral` when the screen is at least 180×30, `stack` below that |
125
+
126
+ `Tab` cycles through them in that order. New sessions start in `auto`, or in
127
+ whatever `layout` your [config](#configuration) names.
128
+
129
+ ### Shaping the master area
130
+
131
+ `tall`, `wide` and `centered` have a master area, and like xmonad you can size
132
+ it and fill it. `<` / `>` shrink or grow the master's share of the screen in
133
+ 5% steps, between 10% and 90%. `,` / `.` take a pane out of the master area or
134
+ add one to it, so two panes can stand side by side as masters while the rest
135
+ stack beside them. Both work in normal mode and after `C-a`. `:ratio 60` and
136
+ `:masters 2` set them directly. The shape is flashed on every change and saved
137
+ with the session, and at the defaults (50%, one master) every layout is
138
+ exactly what it always was.
139
+
140
+ ### Zoom
141
+
142
+ `z` (or `C-a z`, or `:zoom`) takes the focused pane full screen, and pressing
143
+ it again restores the layout you were in. It is monocle with a way back: the
144
+ status bar reads `layout:zoom:tall` while zoomed, so you can see what `z` will
145
+ return to. Picking a layout yourself forgets the zoom, and a session saved
146
+ while zoomed saves the layout underneath.
49
147
 
50
148
  <table>
51
149
  <tr>
52
- <td align="center"><strong>tall</strong><br/>master + stacked slaves</td>
53
- <td align="center"><strong>wide</strong><br/>master on top, slaves below</td>
54
- <td align="center"><strong>columns</strong><br/>equal-width strips</td>
150
+ <td align="center"><strong>tall</strong></td>
151
+ <td align="center"><strong>wide</strong></td>
152
+ <td align="center"><strong>columns</strong></td>
55
153
  </tr>
56
154
  <tr>
57
- <td><img src="docs/screenshots/01-layout-tall.png" alt="tall layout"></td>
58
- <td><img src="docs/screenshots/05-layout-wide.png" alt="wide layout"></td>
59
- <td><img src="docs/screenshots/06-layout-columns.png" alt="columns layout"></td>
155
+ <td><img src="docs/screenshots/layout-tall.png" alt="tall layout"></td>
156
+ <td><img src="docs/screenshots/layout-wide.png" alt="wide layout"></td>
157
+ <td><img src="docs/screenshots/layout-columns.png" alt="columns layout"></td>
60
158
  </tr>
61
159
  <tr>
62
- <td align="center"><strong>rows</strong><br/>equal-height strips</td>
63
- <td align="center"><strong>grid</strong><br/>even tiling</td>
64
- <td align="center"><strong>spiral</strong><br/>Fibonacci spiral</td>
160
+ <td align="center"><strong>rows</strong></td>
161
+ <td align="center"><strong>grid</strong></td>
162
+ <td align="center"><strong>spiral</strong></td>
65
163
  </tr>
66
164
  <tr>
67
- <td><img src="docs/screenshots/07-layout-rows.png" alt="rows layout"></td>
68
- <td><img src="docs/screenshots/02-layout-grid.png" alt="grid layout"></td>
69
- <td><img src="docs/screenshots/08-layout-spiral.png" alt="spiral layout"></td>
165
+ <td><img src="docs/screenshots/layout-rows.png" alt="rows layout"></td>
166
+ <td><img src="docs/screenshots/layout-grid.png" alt="grid layout"></td>
167
+ <td><img src="docs/screenshots/layout-spiral.png" alt="spiral layout"></td>
70
168
  </tr>
71
169
  <tr>
72
- <td align="center"><strong>centered</strong><br/>master flanked by slaves</td>
73
- <td align="center"><strong>stack</strong><br/>accordion of title slivers</td>
74
- <td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
170
+ <td align="center"><strong>centered</strong></td>
171
+ <td align="center"><strong>stack</strong></td>
172
+ <td align="center"><strong>monocle</strong></td>
75
173
  </tr>
76
174
  <tr>
77
- <td><img src="docs/screenshots/09-layout-centered.png" alt="centered layout"></td>
78
- <td><img src="docs/screenshots/10-layout-stack.png" alt="stack layout"></td>
79
- <td><img src="docs/screenshots/03-layout-monocle.png" alt="monocle layout"></td>
175
+ <td><img src="docs/screenshots/layout-centered.png" alt="centered layout"></td>
176
+ <td><img src="docs/screenshots/layout-stack.png" alt="stack layout"></td>
177
+ <td><img src="docs/screenshots/layout-monocle.png" alt="monocle layout"></td>
80
178
  </tr>
81
179
  </table>
82
180
 
83
- The Quake-style drawer overlay (`~` in normal mode, `C-a ~` in passthrough):
181
+ ## Reading the screen
84
182
 
85
- ![drawer overlay](docs/screenshots/04-drawer.png)
183
+ ```
184
+ ┌─ #1 api ★ · npm test ─────── [NORMAL] ─┬─ #2! c2e810 ─────────────┐
185
+ │ master pane (running npm test) │ stacked pane that rang │
186
+ │ ├──────────────────────────┤
187
+ │ │ #3• 9b1d04 [P] │
188
+ │ │ private pane, new output │
189
+ └────────────────────────────────────────┴──────────────────────────┘
190
+ [NORMAL] [work] panes:3 layout:tall focused:#1 alerts:2!,3• drawer:hidden
191
+ ```
192
+
193
+ Each pane's title carries its slot (`#1`, `#2`, …) and a stable six-hex id
194
+ (`a3f9b2`), or the name you gave it with `:rename`. The slot is positional and shifts as panes are created, closed, or
195
+ promoted; the id is minted once and survives layout changes, detach/reattach,
196
+ a move to another session, and a cold restart from the session JSON. `★` marks
197
+ the layout master, `[P]` marks a [private pane](#private-panes), and
198
+ `@api:b338b0` marks a pane [borrowed from another session](#sharing-and-moving-panes).
199
+ A mark straight after the slot means the pane
200
+ [wants your attention](#bells-activity-and-silence): `!` it rang the bell,
201
+ `~` it went quiet, `•` it printed while you were looking elsewhere.
202
+ `[silence 30s]` means a silence monitor is armed on it.
203
+
204
+ When something other than the shell is in the foreground, the title shows it
205
+ (`· npm test`). A background thread polls each pane's foreground process group
206
+ roughly every 750ms so this stays current without ever blocking the render loop.
207
+
208
+ The `[MODE]` chip in the top-right corner and the focused pane's border colour
209
+ both track the current mode: **cyan** normal, **green** passthrough, **orange**
210
+ scrollback and its `/` search, **magenta** selection, **yellow** the command
211
+ prompt, **red** a `y/n` confirmation, **blue** while help is open. Unfocused
212
+ panes use the grey border, except while [`:sync`](#typing-into-every-pane-at-once)
213
+ is on, when they turn red because they receive your keystrokes too.
214
+
215
+ ## Keybindings
216
+
217
+ muxr has two top-level input modes, modelled on vim.
218
+
219
+ **Normal mode** is the default at startup — single keys act on the
220
+ multiplexer, no prefix required.
221
+
222
+ | Keys | Action |
223
+ |------|--------|
224
+ | `h` `j` `k` `l` | focus pane left / down / up / right (spatial) |
225
+ | `H` `J` `K` `L` | move the focused pane left / down / up / right |
226
+ | `i` | drop into passthrough mode |
227
+ | `c` / `x` | new pane / close focused pane (asks `y/n`) |
228
+ | `t` `w` `g` `m` | layout: tall / wide / grid / monocle |
229
+ | `\|` `-` `f` `e` `S` | layout: columns / rows / spiral / centered / stack |
230
+ | `F` | layout: auto — `spiral` on a roomy screen, `stack` below the threshold |
231
+ | `Tab` / `Enter` | cycle layout / promote focused pane to master |
232
+ | `<` / `>` | shrink / grow the master area |
233
+ | `,` / `.` | one fewer / one more master pane |
234
+ | `z` | zoom the focused pane / restore the layout |
235
+ | `a` / `1`…`9` | toggle last pane / jump to pane by number |
236
+ | `r` | refresh — repaint the pane and nudge its program to redraw |
237
+ | `s` | enter scrollback / copy-mode |
238
+ | `~` / `C` / `P` | drawer / Claude Code drawer / toggle private flag |
239
+ | `A` | share or move in a pane from another muxr session |
240
+ | `]` | paste the internal yank buffer into the focused pane |
241
+ | `:` / `?` | command prompt / help |
242
+ | `d` / `q` | detach / kill session (asks `y/n`) |
243
+
244
+ `hjkl` is true spatial navigation: it inspects the current layout's rectangles
245
+ and picks the nearest neighbour in that direction. `HJKL` swaps the focused
246
+ pane with that neighbour and keeps focus on the moved pane, so you can keep
247
+ dragging. Swapping into slot 0 promotes the pane to master. In monocle, where
248
+ every pane owns the whole area, both fall back to linear next/previous.
249
+
250
+ **Passthrough mode** (entered with `i`) forwards every keystroke to the
251
+ focused pane, exactly like a plain terminal. muxr's own commands move behind
252
+ the historical `Ctrl-a` prefix.
253
+
254
+ | Keys | Action |
255
+ |------|--------|
256
+ | `C-a Esc` | return to normal mode |
257
+ | `C-a c` / `C-a x` | new pane / close focused pane (asks `y/n`) |
258
+ | `C-a n` / `C-a p` / `C-a a` | focus next / previous / last pane |
259
+ | `C-a 1`…`9` | jump to pane by number |
260
+ | `C-a Tab` / `C-a Enter` | cycle layout / promote to master |
261
+ | `C-a <` `C-a >` / `C-a ,` `C-a .` | master size / master count |
262
+ | `C-a z` | zoom the focused pane / restore the layout |
263
+ | `C-a r` | refresh / redraw |
264
+ | `C-a ~` / `C-a C` / `C-a P` | drawer / Claude Code drawer / toggle private |
265
+ | `C-a A` | share or move in a pane from another session |
266
+ | `C-a [` / `C-a ]` | scrollback (or the app's own scroll) / paste yank buffer |
267
+ | `C-a d` / `C-a q` | detach / kill session (asks `y/n`) |
268
+ | `C-a :` / `C-a ?` | command prompt / help |
269
+ | `C-a C-a` | send a literal `Ctrl-a` to the focused pane |
270
+
271
+ The prefix is `Ctrl-a` unless your [config](#configuration) says otherwise,
272
+ and every binding in both tables can be remapped there.
273
+
274
+ Layouts are chosen by name from the command prompt in passthrough
275
+ (`C-a :layout grid`), or by cycling with `C-a Tab`; the single-key layout
276
+ bindings live in normal mode.
277
+
278
+ ### The command prompt
279
+
280
+ `:` (normal) or `C-a :` (passthrough) opens a command line across the status
281
+ bar. `Tab` completes command names and their arguments, `Esc` or `Ctrl-c`
282
+ cancels.
283
+
284
+ ![the command prompt completing layout names](docs/screenshots/command-prompt.png)
285
+
286
+ ```
287
+ layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle|auto}
288
+ # any unambiguous prefix works (t, w, r, g, m, …);
289
+ # ambiguous ones (c, s) flash the candidates.
290
+ # `layout` with no argument cycles.
291
+ drawer {toggle|show|hide|reset}
292
+ claude # toggle the Claude Code drawer
293
+ private # toggle the private flag on the focused pane
294
+ attach # open the pane picker (same as A)
295
+ save # write ~/.muxr/sessions/<name>.json
296
+ restore # print the path to the saved session
297
+ sessions | ls # list saved sessions and live servers
298
+ rename [name] # label the focused pane; bare clears it
299
+ silence {30|30s|2m|off} # alert when the focused pane goes quiet
300
+ sync [on|off] # type into every pane at once; bare toggles
301
+ ratio <percent> # the master's share of the screen (10–90)
302
+ masters <n> # how many panes share the master area
303
+ zoom # same as z
304
+ capture [path] # save the pane's history as plain text
305
+ reload # re-read ~/.muxr/config.json
306
+ new | close | next | prev | master | detach | quit | help
307
+ ```
308
+
309
+ ## Bells, activity, and silence
310
+
311
+ The bell and OSC 9 / OSC 777 desktop notifications are forwarded to your real
312
+ terminal from *any* pane, so a background pane can still get your attention.
313
+ muxr also remembers **which** pane it was, so "something finished" becomes
314
+ something you can act on:
315
+
316
+ | Mark | Meaning |
317
+ |------|---------|
318
+ | `#2!` | the pane rang the bell or sent a desktop notification |
319
+ | `#2~` | the pane went quiet for longer than its silence monitor allows |
320
+ | `#2•` | the pane printed something while you were looking elsewhere |
321
+
322
+ The marks also collect in the status bar as `alerts:2!,3•`, and all of them
323
+ clear the moment you focus the pane. The pane you are looking at is never
324
+ marked, unless no client is attached, in which case nobody is looking at it
325
+ either. Output in the 1.5 seconds after a pane is created or resized does not
326
+ count: every layout change makes every shell redraw its prompt, and those
327
+ redraws would otherwise mark every pane on screen.
328
+
329
+ `:silence 30` arms a **silence monitor** on the focused pane, for the build,
330
+ deploy or agent that tells you it is done by going quiet. Once the pane has
331
+ printed nothing for that long, muxr flashes `pane #2 silent for 30s`, rings
332
+ your terminal's bell, and marks the pane `~`. It fires once per quiet spell
333
+ and re-arms on the next output. It takes `30`, `30s` or `2m`, `:silence off`
334
+ disarms it, and a bare `:silence` reports the setting. An armed pane shows
335
+ `[silence 30s]` in its title, and the threshold is saved with the session.
336
+
337
+ ## Typing into every pane at once
338
+
339
+ `:sync` broadcasts what you type in passthrough mode to every pane in the
340
+ window, which makes running the same command on several hosts or checkouts one
341
+ keystroke instead of several. `C-a ]` pastes are broadcast too. While it is on,
342
+ the status bar carries a red `[SYNC]` chip and every unfocused pane gets a red
343
+ border. `:sync off` (or a bare `:sync`, which toggles) ends it.
344
+
345
+ Borrowed panes are included, and your keystrokes reach their real shells. The
346
+ drawer is left out in both directions: when the drawer is focused, what you
347
+ type stays in the drawer. Sync is deliberately never saved with the session.
348
+ There is no default key for it, but you can [bind one](#configuration).
349
+
350
+ ## The drawer
351
+
352
+ `~` (normal), `C-a ~` (passthrough), or `:drawer toggle` drops a persistent
353
+ overlay shell over the top of the layout — the terminal equivalent of a Quake
354
+ console. It is the right place for the command you keep needing but do not
355
+ want to give a pane to.
356
+
357
+ ![the drawer overlay](docs/screenshots/drawer.png)
358
+
359
+ Hiding the drawer **never tears down its PTY**: the shell keeps running, so
360
+ the next toggle restores exactly what was on screen, scrollback and all. Only
361
+ `:drawer reset` kills and respawns it. Like every new pane, the drawer starts
362
+ in the session's origin directory — wherever `muxr` was first launched.
363
+
364
+ ## Scrollback, search, and copy-mode
365
+
366
+ Every pane keeps a bounded scrollback ring — 50,000 rows by default, or
367
+ whatever `MUXR_SCROLLBACK` is set to when the server starts. A row costs
368
+ roughly what it printed rather than the full width of the pane, and a pane
369
+ only pays for the rows it has actually scrolled, so the default is deep enough
370
+ to stop thinking about. `s` (normal) or `C-a [` (passthrough) enters
371
+ scrollback with vi-style navigation; the pane title gains `[scrollback N/M]`
372
+ and the border turns orange.
373
+
374
+ Full-screen programs — pagers, editors, `fzf`, anything that asks for the
375
+ alternate screen — draw on a grid of their own, so paging through `less` does
376
+ not shovel its frames into your history, and quitting uncovers the screen you
377
+ started from.
378
+
379
+ | Keys | Action |
380
+ |------|--------|
381
+ | `j` `k` or `↓` `↑` | scroll one line |
382
+ | `d` `u`, `C-d` `C-u`, `PgDn` `PgUp` | half page |
383
+ | `f` `b`, `C-f` `C-b`, Space | full page |
384
+ | `g` `G`, `Home` `End` | top / bottom |
385
+ | `Tab` | switch between the app's own scroll and muxr's history |
386
+ | `/`*query*`Enter` | search forward; `?` searches backward |
387
+ | `n` / `N` | next / previous match in the search direction (wraps) |
388
+ | `v` | enter visual selection |
389
+ | `i` | drop into passthrough here, keeping your scroll position |
390
+ | `q` `Esc` `C-c` | back to normal mode at the live bottom |
391
+
392
+ ### Scrolling the program instead of the history
393
+
394
+ Some programs keep their own history and want to do their own scrolling —
395
+ `lazygit`, `k9s`, `htop`. They say so by turning on mouse tracking, and on
396
+ such a pane the same keys drive *them* rather than muxr's ring: muxr
397
+ synthesises wheel events at the centre of the pane and writes them straight
398
+ into the pty, so scrolling reaches the program's own view without you
399
+ touching the mouse. The mode chip reads `SCROLL:APP`. A full-screen program
400
+ that wants no mouse gets arrow keys instead, which is what makes `less` and
401
+ `vim` respond — and is what this mostly buys you in practice.
402
+
403
+ Claude Code is deliberately not in that list. It renders inline on the
404
+ primary screen and enables no mouse tracking, so it has no scroll of its own
405
+ to hand off to: its transcript lives in the host terminal's scrollback, which
406
+ inside muxr *is* muxr's ring. `C-a [` on such a pane keeps the ring, which is
407
+ the right answer rather than a fallback.
408
+
409
+ `Tab` switches between the two at any time — the app's own scroll, or muxr's
410
+ ring for output older than the program will page back to. `g`/`G` and search
411
+ belong to the ring, since only the program knows where its history starts.
412
+
413
+ Visual selection works in both, with one restriction. A program that scrolls
414
+ itself repaints in place, so nothing it scrolls past reaches muxr's ring, and
415
+ the ring's tail no longer continues into the top of the screen. Selection is
416
+ therefore clamped to the visible screen while you are scrolling the app; press
417
+ `Tab` first if you want to select out of muxr's history.
418
+
419
+ Search is smart-case (case-insensitive unless the query contains an uppercase
420
+ letter), scans the scrollback ring and the live buffer together, and centres
421
+ the chosen match in the viewport. A full 50,000-row ring searches in well
422
+ under a tenth of a second. Matches stay highlighted in yellow for as long as
423
+ you are in scrollback.
424
+
425
+ ![scrollback search highlighting every match](docs/screenshots/scrollback-search.png)
426
+
427
+ Scrollback is **pane-bound**, not modal: `C-a n` / `C-a p` / `C-a 1`…`9` work
428
+ from inside it, each pane remembers where you were reading, and returning to a
429
+ scrolled-back pane resumes there.
430
+
431
+ Press `v` for a movable-cursor selection with vim motions:
432
+
433
+ | Keys | Action |
434
+ |------|--------|
435
+ | `h` `j` `k` `l` | move the cursor |
436
+ | `0` `^` `$` | line start / first non-blank / line end |
437
+ | `w` `W` `e` `E` `b` `B` | word and WORD motions |
438
+ | `g` `G` | top / bottom of the timeline |
439
+ | `H` `M` `L` | top / middle / bottom of the viewport |
440
+ | `C-d` `C-u` `C-f` `C-b` Space | half and full page |
441
+ | `v` / `C-v` | toggle character / block (rectangular) selection |
442
+ | `y` or `Enter` | yank and stay in scrollback |
443
+ | `q` `Esc` `C-c` | cancel back to scrollback |
444
+
445
+ ![a visual selection swept over six lines, ready to yank](docs/screenshots/selection.png)
446
+
447
+ Switching between `v` and `C-v` preserves the anchor. Yanking fills muxr's
448
+ internal buffer *and* pipes the text to `pbcopy` in the background (a silent
449
+ no-op where `pbcopy` does not exist). `]` / `C-a ]` writes the buffer back
450
+ into the focused pane.
451
+
452
+ ### Capturing a whole history
453
+
454
+ `:capture` writes the focused pane's full scrollback and screen to a
455
+ plain-text file, for the build log or transcript that is too long to yank a
456
+ screen at a time. Escape codes are dropped, trailing spaces are trimmed, and
457
+ wide glyphs come out whole. With no argument it writes
458
+ `~/.muxr/captures/<session>-<pane>-<timestamp>.txt`; `:capture notes/run.txt`
459
+ writes where you say, resolving a relative path against the directory the
460
+ session was started in. While a full-screen program is up, the capture holds
461
+ the shell underneath rather than the program's frame.
462
+
463
+ ## Sharing and moving panes
464
+
465
+ `A` (or `C-a A`, or `:attach`) opens a picker listing every pane the other
466
+ muxr servers on this machine are offering, grouped by session. `j`/`k` select,
467
+ `Esc` backs out, and there are two ways to take one.
468
+
469
+ ![the pane picker listing panes from other sessions](docs/screenshots/pane-picker.png)
470
+
471
+ | Key | Action |
472
+ |-----|--------|
473
+ | `Enter` | **share** it — the pane lives in both sessions at once |
474
+ | `m` | **move** it here — the pane leaves the session it came from |
475
+
476
+ ### Sharing
477
+
478
+ A shared pane keeps running in its own session, and both sessions show the
479
+ same live shell. Either side can type into it; colours, the alternate screen,
480
+ and full-screen TUIs all work, because what crosses between the servers is the
481
+ raw PTY byte stream rather than a screen scrape. The borrowed pane's title
482
+ names its owner: `#2 922ece @api:b338b0`.
483
+
484
+ ![a pane borrowed from another session](docs/screenshots/pane-share.png)
485
+
486
+ The owner keeps the PTY and remains its only reader, which settles every
487
+ question a shared pane raises:
488
+
489
+ - **Size.** The PTY runs at the smallest viewport looking at it, so it fits
490
+ both layouts at once. Give a borrowed pane a small box and it shrinks at
491
+ home too — exactly like tmux.
492
+ - **The owning session stops.** The mirror's socket closes and the borrower
493
+ drops the pane on its next tick.
494
+ - **The borrowing session stops** (or you close the pane with `x`). Only the
495
+ mirror goes away; the pane carries on at home and returns to full size the
496
+ next time its own layout is drawn.
497
+ - **Private panes** are never offered in the picker.
498
+ - Borrowed panes are left out of `:save` — restoring one would cold-start a
499
+ second shell in someone else's working directory.
500
+
501
+ ### Moving
502
+
503
+ `m` takes the pane instead of borrowing it. The master pty **file descriptor
504
+ itself** crosses the socket, so this is a real handover rather than a
505
+ re-spawn: the same shell process keeps running, with its environment, its
506
+ background jobs, its scroll position, and everything it had on screen.
507
+ Afterwards it is an ordinary local pane that simply is not in the other
508
+ session any more.
509
+
510
+ What travels: the process, the screen, the full scrollback, the pane id, the
511
+ working directory, and the emulator's mode state (scroll region, bracketed
512
+ paste, cursor visibility, the current pen). Move a pane while it is running a
513
+ pager and both grids come with it, so quitting the pager still uncovers the
514
+ shell that was underneath.
515
+
516
+ The handover is two-phase, so a failure anywhere leaves the pane exactly where
517
+ it was rather than dropping a live shell between two servers; an abandoned
518
+ move resumes at the owner after ten seconds. Two refusals are deliberate:
519
+ muxr will not move the **last** pane out of a session (that would shut the
520
+ session down as a side effect), and a pane that is itself borrowed cannot be
521
+ moved on — move it from the session that owns it.
522
+
523
+ ## Terminal fidelity
524
+
525
+ The per-pane `Terminal` is a real VT100/xterm emulator, not a line buffer: a
526
+ `rows × cols` grid of cells with a cursor, a scroll region, and a pen.
527
+
528
+ - **Colour and attributes.** 16-colour, 256-colour and truecolor SGR,
529
+ including colon-subparameter and underline-colour forms.
530
+ - **Alternate screen.** A pager, editor or `fzf` that asks for the alternate
531
+ screen gets a second grid to draw on, and the screen it covered is set aside
532
+ untouched until it exits. Nothing drawn there is history, so its frames never
533
+ enter the scrollback ring.
534
+ - **Wide and combining characters.** CJK, emoji, and zero-width marks are
535
+ measured and stored with a continuation-cell convention, so a grid row
536
+ containing them still lines up — including search highlights and selection.
537
+ - **Width probing.** Terminals disagree about how wide East Asian Ambiguous
538
+ symbols (`·`, `…`, `●`, arrows) and box-drawing glyphs really are, and one
539
+ disagreement is enough to shift the rest of a line. Rather than guess, muxr
540
+ *measures*: on attach the client prints test glyphs and reads the cursor
541
+ column back via DSR-CPR, then ships the verdict to the server. This is what
542
+ keeps border-heavy TUIs — Claude Code's UI in particular — aligned.
543
+ - **Clickable links.** Plain `http`/`https`/`ftp` URLs that wrap across rows
544
+ are re-stamped with matching OSC 8 hyperlink ids, so Ghostty, iTerm2, kitty
545
+ and WezTerm merge the halves back into one clickable link. Program-emitted
546
+ OSC 8 payloads are passed through untouched.
547
+ - **Clipboard passthrough.** An inner program that writes the system clipboard
548
+ over OSC 52 (a vim yank, tmux, Ghostty) reaches `pbcopy` for real, and the
549
+ same text lands in muxr's yank buffer.
550
+ - **Notifications.** The bell and OSC 9 / OSC 777 desktop notifications are
551
+ forwarded out of band, from *any* pane — so "Claude finished in a background
552
+ pane" still gets your attention.
553
+
554
+ ### Images
555
+
556
+ A multiplexer that re-composites a cell grid every frame has nowhere to put
557
+ pixels, so muxr does not draw images — it saves them. When an inner program
558
+ transmits one over the kitty graphics protocol (matplotlib's kitty backend,
559
+ `timg`, `icat`, notebook TUIs), muxr decodes it into `~/.muxr/images` and
560
+ prints one line in the pane:
561
+
562
+ ![kitty-protocol images saved to disk and announced as a clickable path](docs/screenshots/images.png)
563
+
564
+ The line carries an OSC 8 `file://` hyperlink, so Cmd-click opens it in your
565
+ image viewer. Multi-chunk transmissions are reassembled, raw RGB/RGBA pixel
566
+ data is re-encoded into a real PNG container, and the 200 most recent images
567
+ are kept. muxr answers the protocol's capability query, so programs that probe
568
+ choose kitty over sixel; sixel itself is consumed and discarded rather than
569
+ decoded.
570
+
571
+ ## Control surface and MCP
572
+
573
+ Alongside the TTY socket, each server exposes a control listener at
574
+ `~/.muxr/sockets/<name>.ctrl.sock` speaking newline-delimited JSON-RPC:
86
575
 
87
- Scrollback / copy-mode (`s`) with `/` search — matches highlight in
88
- yellow and the focused pane border turns orange:
576
+ ```
577
+ session.get panes.list pane.read pane.send_input pane.run
578
+ pane.focus pane.new pane.kill pane.promote pane.redraw
579
+ pane.subscribe / unsubscribe pane.mirror / mirror_resize / unmirror
580
+ pane.move / move_commit / move_abort layout.set / layout.cycle
581
+ drawer.toggle / show / hide / reset / read / send_input session.save
582
+ ```
89
583
 
90
- ![scrollback search](docs/screenshots/05-scrollback-search.png)
584
+ Anywhere a method takes a `pane`, it accepts the pane's id, its slot number,
585
+ or the name a human gave it with `:rename`. An id always wins over a name that
586
+ looks like one, and a name two panes share is refused rather than guessed.
91
587
 
92
- Movable-cursor visual selection (`v` inside scrollback) — the border
93
- turns magenta and the swept region is highlighted, ready to yank with
94
- `y`:
588
+ It accepts many concurrent clients and is independent of TTY attach —
589
+ programmatic callers never count as "attached", so an agent and a human can
590
+ drive the same session at once.
95
591
 
96
- ![visual selection](docs/screenshots/06-selection.png)
592
+ Two details make automation over it reliable rather than racy:
97
593
 
98
- ## Install / run
594
+ - **`pane.run` waits for the PTY to go idle** before responding. It sends the
595
+ input, polls for output, and returns once no bytes have arrived for
596
+ `idle_ms` (default 500). Server-side idle detection avoids the
597
+ send-then-poll race that plagues naive client-side automation.
598
+ - **Keys are named, not escaped.** `pane.send_input`, `pane.run`, and
599
+ `drawer.send_input` accept a `keys` array of vim-style tokens (`<esc>`,
600
+ `<c-c>`, `<cr>`, arrows) interleaved with literal text, so callers never
601
+ have to remember that Escape is `"\e"`. Bracketed-paste wrapping still
602
+ applies to literal segments only.
603
+
604
+ ### Claude Code integration
99
605
 
100
606
  ```bash
101
- gem install muxr
102
- muxr # attach the "default" session (auto-spawn if needed)
103
- muxr work # attach (or start) a named session
104
- muxr --list # list running sessions and exit
105
- muxr --install-skill # install the MCP skill into ~/.claude/skills
106
- muxr --help
607
+ muxr --install-skill # installs the skill into ~/.claude/skills and
608
+ # prints the `claude mcp add` registration line
107
609
  ```
108
610
 
109
- Requires **Ruby ≥ 3.4**. No runtime gems — just `PTY`, `IO.console`, `JSON`,
110
- `Socket`, and `FileUtils` from stdlib.
611
+ `bin/muxr-mcp` is a standalone MCP-over-stdio bridge that turns Claude Code
612
+ tool calls into control-socket requests. It exposes the surface above as
613
+ `muxr_pane_run`, `muxr_pane_read`, `muxr_layout_set`, `muxr_drawer_*` and
614
+ friends, and finds its target session from `MUXR_CONTROL_SOCKET` or
615
+ `MUXR_SESSION`.
616
+
617
+ **Every PTY muxr spawns gets those two vars**, so any `claude` you start from
618
+ any pane is wired to the session it is sitting in — no drawer required, and
619
+ nothing to configure per pane. Register the bridge once at user scope
620
+ (`claude mcp add muxr muxr-mcp --scope user`) and it is simply always there.
621
+ A pane's shell also gets `MUXR_PANE`, its own pane id; the bridge refuses
622
+ `muxr_pane_read`, `muxr_pane_send_input`, `muxr_pane_run` and
623
+ `muxr_pane_kill` aimed at that pane, by id or by name, since a claude driving
624
+ its own pty feeds its output back to itself. `muxr_pane_focus` and `muxr_pane_promote` are
625
+ harmless on yourself and stay allowed.
626
+
627
+ Because the bridge is registered for *every* claude session, including ones
628
+ nowhere near a muxr, it starts whether or not a session is reachable: with no
629
+ socket to talk to it completes the MCP handshake, advertises **no** tools, and
630
+ explains itself if called anyway. It connects on first use and reconnects on
631
+ its own, so a pane's claude survives a `C-a q` and restart of the session
632
+ around it.
633
+
634
+ Those vars are a snapshot of where the pane was when its shell started, and a
635
+ pane can be **moved to another session** while that shell keeps running — a
636
+ running process's environment can't be rewritten from outside, so the env goes
637
+ stale. `MUXR_PANE` is what makes this recoverable: when the session the env
638
+ names no longer lists that pane as its own, the bridge asks the other sockets
639
+ in the same directory which of them does, and talks to that one instead. It
640
+ re-checks on a ten-second TTL, so a pane moved out from under a running claude
641
+ is followed rather than leaving it driving its old session. A mirrored pane
642
+ carries `origin` and is never mistaken for the real owner.
643
+
644
+ `C` (normal), `C-a C` (passthrough), or `:claude` opens a drawer whose shell
645
+ is `claude`, additionally carrying `MUXR_FOCUSED_PANE` and
646
+ `MUXR_DRAWER_SELF=1`. You get a Quake-style Claude Code overlay that already
647
+ knows which pane you were looking at. `MUXR_DRAWER_SELF` makes the bridge
648
+ refuse `drawer.*` methods, so the drawer cannot recurse into its own PTY —
649
+ the drawer's equivalent of `MUXR_PANE`.
650
+
651
+ How the skill installs depends on where muxr runs from. An **installed gem**
652
+ is **copied**, because RubyGems prunes the old versioned directory on upgrade
653
+ and a symlink into it would dangle — re-run `muxr --install-skill` after each
654
+ `gem update muxr`. A **source checkout** is **symlinked**, so edits to
655
+ `SKILL.md` are live in new Claude sessions. Force either with
656
+ `--install-skill=copy` or `--install-skill=link`.
111
657
 
112
- `muxr` is the client. The first invocation for a session daemonizes a
113
- server in the background; subsequent invocations attach to it over a Unix
114
- socket. `d` (normal mode) / `C-a d` (passthrough) detaches the client
115
- and leaves the server (and every shell it owns) running, so reattaching
116
- gives you back the exact same panes with their full history.
658
+ ### Private panes
117
659
 
118
- ### From source
660
+ `P` (normal), `C-a P` (passthrough), or `:private` flips the private flag on
661
+ the focused pane. Private panes are hidden from programmatic callers:
662
+ `panes.list` strips their cwd and dimensions, and `pane.read`,
663
+ `pane.send_input`, `pane.run`, `pane.subscribe`, and `pane.kill` refuse with
664
+ an error pointing the human back at the TTY. They are also never offered in
665
+ another session's pane picker.
119
666
 
120
- To run the latest unreleased code or hack on muxr locally, clone the repo
121
- and use `bin/muxr` directly — it puts `lib/` on `$LOAD_PATH` itself:
667
+ ![a pane marked private](docs/screenshots/private-pane.png)
122
668
 
123
- ```bash
124
- git clone https://github.com/roelbondoc/muxr
125
- cd muxr
126
- bin/muxr # same flags as the installed `muxr` executable
127
- ```
669
+ The flag is persisted in the session JSON and shown as `[P]` in the title. The
670
+ control surface deliberately has **no method to flip it** — only a human at
671
+ the keyboard can make a pane public again.
128
672
 
129
- ## Modes
130
-
131
- muxr has two top-level input modes, modeled on vim:
132
-
133
- - **Normal** (default at startup) — single keys act on the multiplexer.
134
- `hjkl` moves focus between panes, `HJKL` moves the focused pane
135
- itself, `c`/`x` create/close panes, `t`/`w`/`g`/`m` (and `|`/`-`/`f`/`e`/`S`) set the layout, etc.
136
- No prefix needed.
137
- - **Passthrough** (entered with `i`) — every keystroke is forwarded to
138
- the focused pane, exactly like a regular terminal. muxr commands are
139
- reached via the historical `Ctrl-a` prefix. `Ctrl-a Esc` returns to
140
- normal mode.
141
-
142
- The active mode appears as a `[MODE]` chip in the top-right corner of
143
- the focused pane (and the leftmost slot of the status bar). The
144
- focused pane's border is colored by mode — cyan for normal, green for
145
- passthrough, orange for scrollback (and its `/` search prompt),
146
- magenta for selection, yellow for the command prompt, red during the
147
- kill-session or close-pane confirmation, blue while help is open.
148
- Unfocused panes always render with the grey unfocused border,
149
- regardless of mode.
150
-
151
- ### Normal mode
152
-
153
- | Keys | Action |
154
- |----------------------|-----------------------------------------------------|
155
- | `h` / `j` / `k` / `l`| focus pane left / down / up / right (spatial) |
156
- | `H` / `J` / `K` / `L`| move focused pane left / down / up / right |
157
- | `i` | drop into passthrough mode |
158
- | `c` / `x` | new / close focused pane (close asks `y/n`) |
159
- | `t` / `w` / `g` / `m`| layout: tall / wide / grid / monocle |
160
- | `\|` / `-` / `f` / `e` / `S` | layout: columns / rows / spiral / centered / stack |
161
- | `Tab` / `Enter` | cycle layout / promote focused to master |
162
- | `a` / `1` … `9` | toggle last pane / jump to pane by number |
163
- | `s` | enter scrollback / copy-mode |
164
- | `~` / `C` / `P` | drawer / Claude drawer / toggle private flag |
165
- | `]` | paste internal yank buffer into focused pane |
166
- | `:` / `?` | command prompt / help |
167
- | `d` / `q` | detach / kill session (asks `y/n`) |
168
-
169
- `h`/`j`/`k`/`l` does true spatial navigation — it inspects the current
170
- layout's rectangles and picks the closest neighbor in the requested
171
- direction. In monocle (where every pane occupies the full area) it
172
- falls back to linear next/previous so the keys still do something
173
- useful.
174
-
175
- `H`/`J`/`K`/`L` swap the focused pane with the spatial neighbor in
176
- that direction, then keep focus on the moved pane so you can keep
177
- dragging it. Swapping into position 0 of `tall`/`grid` promotes the
178
- pane to master (the layout master is always `panes[0]`). In monocle
179
- the move falls back to linear next/prev shuffling.
180
-
181
- ### Passthrough mode (`Ctrl-a` prefix)
182
-
183
- | Keys | Action |
184
- |----------------|---------------------------------------------------------|
185
- | `C-a Esc` | return to normal mode |
186
- | `C-a c` | new pane |
187
- | `C-a n` / `p` | focus next / previous pane (linear) |
188
- | `C-a a` | toggle last (previously focused) pane |
189
- | `C-a 1` … `9` | jump to pane by its label |
190
- | `C-a x` | close focused pane (asks `y/n`; hides drawer with no prompt) |
191
- | `C-a Tab` | cycle layout (`tall` → `wide` → `columns` → `rows` → `grid` → `spiral` → `centered` → `stack` → `monocle`) |
192
- | `C-a Enter` | promote focused pane to master |
193
- | `C-a ~` | toggle drawer (shell) |
194
- | `C-a C` | toggle Claude Code drawer (MCP-aware) |
195
- | `C-a P` | toggle private flag on focused pane (hides from MCP) |
196
- | `C-a [` | enter scrollback / copy-mode |
197
- | `C-a ]` | paste internal yank buffer into focused pane |
198
- | `C-a d` | detach (server keeps running) |
199
- | `C-a q` | kill session (asks `kill session? (y/n)`) |
200
- | `C-a :` | command prompt |
201
- | `C-a ?` | help |
202
- | `C-a C-a` | send literal `C-a` to focused pane |
203
-
204
- ### Scrollback and copy-mode
205
-
206
- Each pane keeps a bounded (5000-row) scrollback ring. `s` in normal
207
- mode (or `C-a [` in passthrough) enters scrollback with vi-style
208
- navigation; the status bar shows a key hint and the pane title gains
209
- `[scrollback N/M]`.
210
-
211
- | Keys | Action |
212
- |-------------------------|-------------------------------------|
213
- | `j` / `k` or `↓` / `↑` | scroll one line |
214
- | `d` / `u` (or `C-d`/`C-u`, `PgDn`/`PgUp`) | half page |
215
- | `f` / Space (or `C-f`/`C-b`) | full page |
216
- | `g` / `G` (or `Home` / `End`) | top / bottom |
217
- | `/` *query* `Enter` | search forward (toward newer); `?` searches backward |
218
- | `n` / `N` | next / previous match in the search direction (wraps) |
219
- | `q` / `Esc` / `C-c` | exit back to normal mode |
220
-
221
- Search uses smart-case (case-insensitive unless the query has an
222
- uppercase letter), scans both scrollback and the live buffer, and
223
- centers the chosen match in the viewport. Matches stay highlighted in
224
- yellow while you're in scrollback; exiting clears the highlight.
225
-
226
- Press `v` inside scrollback to enter a movable-cursor selection mode.
227
- Vim-style motions are supported:
228
-
229
- | Keys | Action |
230
- |-------------------------|-------------------------------------|
231
- | `h` / `j` / `k` / `l` | left / down / up / right |
232
- | `0` / `^` / `$` | line start / first non-blank / line end |
233
- | `w` / `W` | next word / WORD start |
234
- | `e` / `E` | next word / WORD end |
235
- | `b` / `B` | previous word / WORD start |
236
- | `g` / `G` | top / bottom of timeline |
237
- | `H` / `M` / `L` | top / middle / bottom of viewport |
238
- | `C-d`/`C-u`, `C-f`/`C-b`, Space | half / full page |
239
- | `v` / `C-v` | anchor char / block selection (toggle) |
240
- | `y` or Enter | yank and return to normal mode |
241
- | `q` / `Esc` / `C-c` | cancel back to scrollback |
242
-
243
- `v` and `C-v` toggle between character and block (rectangular) selection
244
- — switching between the two preserves the anchor. `y` or Enter yanks the
245
- selection into an internal buffer, pipes it to `pbcopy` in the background
246
- (silent no-op when `pbcopy` is unavailable), and returns to normal mode.
247
- `]` (normal) / `C-a ]` (passthrough) writes the yank buffer back into the
248
- focused pane.
249
-
250
- ## Commands (typed after `:` in normal mode, or `C-a :` in passthrough)
673
+ ## Sessions and persistence
251
674
 
252
- ```
253
- layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle}
254
- # any unambiguous name prefix works (t, w, r, g, m, …);
255
- # ambiguous ones (c → columns/centered, s → spiral/stack)
256
- # flash the candidates. layout (no arg) → cycle
257
- drawer {toggle|show|hide|reset}
258
- claude # toggle the Claude Code drawer
259
- private # toggle private flag on focused pane
260
- save # persist session to ~/.muxr/sessions/<name>.json
261
- restore # show path to saved session
262
- sessions | ls # list saved sessions
263
- new | close | next | prev | master
264
- detach | quit # quit asks for y/n confirmation
265
- ```
675
+ `d` / `C-a d` detaches the client and leaves the server running. Reattaching
676
+ gives you back the same shells with their full history, because the live
677
+ session never left the server process. `q` / `C-a q` / `:quit` flash
678
+ `kill session? (y/n)` in the status bar and only tear the server down on `y` —
679
+ there is no kill-without-confirm binding, by design.
266
680
 
267
- ## MCP control surface
681
+ `:save` writes a structural snapshot to `~/.muxr/sessions/<name>.json`:
268
682
 
269
- muxr exposes a second listener at `~/.muxr/sockets/<name>.ctrl.sock`
270
- that accepts multiple concurrent NDJSON clients over a small JSON-RPC
271
- surface (`session.get`, `panes.list`, `pane.read`, `pane.send_input`,
272
- `pane.run`, `pane.subscribe`, `pane.kill`, `layout.set`, `drawer.*`,
273
- …). The control socket is independent of TTY attach — programmatic
274
- clients never count as "attached", so a Claude Code session and a human
275
- can drive the multiplexer concurrently.
683
+ ```json
684
+ {
685
+ "name": "work",
686
+ "layout": "tall",
687
+ "focused_index": 0,
688
+ "master_index": 0,
689
+ "master_ratio": 0.6,
690
+ "master_count": 1,
691
+ "panes": [
692
+ {"id": "a3f9b2", "cwd": "/home/me/code", "private": false, "name": "api"},
693
+ {"id": "c2e810", "cwd": "/tmp", "private": true, "silence": 30}
694
+ ],
695
+ "drawer": {"visible": true, "cwd": "/home/me/code"}
696
+ }
697
+ ```
276
698
 
277
- `pane.run` waits for the PTY to go idle before responding: it sends the
278
- input, polls for output, and returns once no bytes have arrived for
279
- `idle_ms` (default 500). Server-side idle detection avoids the
280
- send-then-poll race that plagues naive client-side automation.
699
+ That file is a **cold-storage fallback**, not the source of truth. It only
700
+ matters once the server is gone (after `q`, or a reboot): relaunching
701
+ `muxr <name>` rebuilds the layout and spawns fresh shells in the saved working
702
+ directories, keeping the same pane ids, names, private flags, silence
703
+ monitors, and master shape. Shell history inside
704
+ those panes is your shell's job, not muxr's.
281
705
 
282
- `pane.send_input`, `pane.run`, and `drawer.send_input` accept a `keys`
283
- array of vim-style `<name>` tokens (`<esc>`, `<c-c>`, `<cr>`, arrows,
284
- etc.) interleaved with literal text — callers don't have to remember
285
- that Escape is `"\e"` and Ctrl-C is `"\x03"`. Bracketed-paste wrapping
286
- still applies to literal segments only.
706
+ ```
707
+ ~/.muxr/
708
+ ├─ config.json your settings (optional; see Configuration)
709
+ ├─ sessions/<name>.json structural snapshot written by `:save`
710
+ ├─ sockets/<name>.sock TTY client listener (auto-managed)
711
+ ├─ sockets/<name>.ctrl.sock control / MCP listener (auto-managed)
712
+ ├─ images/ images decoded out of the kitty protocol
713
+ ├─ captures/ default destination for `:capture`
714
+ └─ logs/<name>.log server stdout and stderr
715
+ ```
287
716
 
288
- ### Claude Code integration
717
+ ## Configuration
289
718
 
290
- ```bash
291
- muxr --install-skill # copies skills/muxr-control into ~/.claude/skills
292
- # and prints the `claude mcp add` registration line
719
+ muxr reads `~/.muxr/config.json` when the server starts, or whatever file
720
+ `MUXR_CONFIG` names. Every setting is optional, and `:reload` re-reads the file
721
+ in a running session.
722
+
723
+ ```json
724
+ {
725
+ "layout": "tall",
726
+ "scrollback": 20000,
727
+ "master_ratio": 0.6,
728
+ "master_count": 1,
729
+ "auto_spiral_min": {"cols": 200, "rows": 40},
730
+ "prefix": "C-b",
731
+ "keys": {
732
+ "normal": {"Z": "toggle_zoom", "Y": ":sync", "q": null},
733
+ "prefix": {"Space": "cycle_layout", "S": ":capture"}
734
+ }
735
+ }
293
736
  ```
294
737
 
295
- `bin/muxr-mcp` is the standalone MCP-over-stdio bridge that translates
296
- Claude Code tool calls into NDJSON requests on the control socket. It
297
- auto-detects the target session from `MUXR_CONTROL_SOCKET` or
298
- `MUXR_SESSION` env vars.
738
+ | Setting | Effect |
739
+ |---------|--------|
740
+ | `layout` | the layout new sessions start in (a restored session keeps its own) |
741
+ | `scrollback` | rows of history per pane; `MUXR_SCROLLBACK` still wins when set |
742
+ | `master_ratio`, `master_count` | the starting master shape, 0.1–0.9 and 1+ |
743
+ | `auto_spiral_min` | the screen size at which `auto` switches from `stack` to `spiral` |
744
+ | `prefix` | the passthrough prefix, any control key: `"C-b"` for tmux habits |
745
+ | `keys.normal`, `keys.prefix` | remap keys in normal mode and after the prefix |
299
746
 
300
- `C` (normal) / `C-a C` (passthrough) / `:claude` opens a drawer whose shell is `claude`, with
301
- `MUXR_SESSION`, `MUXR_CONTROL_SOCKET`, `MUXR_FOCUSED_PANE`, and
302
- `MUXR_DRAWER_SELF=1` injected into its environment. The bridge picks
303
- those up automatically; you get a Quake-style Claude Code overlay that
304
- already knows what session it's in. `MUXR_DRAWER_SELF` makes the bridge
305
- refuse `drawer.*` methods, so a claude drawer can't recurse into its
306
- own PTY.
747
+ A key is one character, `C-x`, `Tab`, `Enter`, `Space` or `Esc`. It maps to
748
+ `null` to unbind it, to a `:` command to run that command (`":sync"`,
749
+ `":silence 30"`, `":capture"`), or to one of the actions the built-in keys use:
307
750
 
308
- ### Private panes
751
+ ```
752
+ new_pane request_close promote_master cycle_layout toggle_zoom
753
+ shrink_master grow_master remove_master add_master
754
+ set_layout:{tall,wide,columns,rows,grid,spiral,centered,stack,monocle,auto}
755
+ focus_direction:{left,down,up,right} move_direction:{left,down,up,right}
756
+ focus_next focus_prev focus_last refresh_focused enter_scrollback
757
+ toggle_drawer toggle_claude_drawer toggle_private_focused open_pane_picker
758
+ paste_from_buffer show_help detach quit_immediate
759
+ ```
309
760
 
310
- `P` (normal) / `C-a P` (passthrough) / `:private` flips the private flag on the focused pane.
311
- Private panes are hidden from programmatic callers: `panes.list` strips
312
- cwd/rows/cols, and `pane.read`, `pane.send_input`, `pane.run`,
313
- `pane.subscribe`, and `pane.kill` refuse with an error message pointing
314
- the human at the TTY (`P` / `C-a P`) to expose it. The flag is persisted in session
315
- JSON and shown as `[P]` in the pane title bar. The MCP surface
316
- intentionally has no method to flip the flag — only a human at the TTY
317
- can mark a pane public again.
761
+ `i`, `:` and `1`…`9` in normal mode, and `Esc`, `:`, the digits and the prefix
762
+ itself after the prefix, are reserved. Anything muxr cannot make sense of (an
763
+ unknown setting, a bad value, a key it cannot parse) is skipped rather than
764
+ fatal: the first problem is flashed when you attach, and all of them go to
765
+ `~/.muxr/logs/<name>.log`.
318
766
 
319
767
  ## Architecture
320
768
 
321
- muxr runs as **two processes** that talk over a Unix domain socket at
322
- `~/.muxr/sockets/<name>.sock`. The server owns the PTYs and all session
323
- state; the client is a thin TTY front-end that comes and goes across
324
- detach/reattach.
769
+ muxr runs as **two processes** talking over a Unix domain socket. The server
770
+ owns the PTYs and all session state; the client is a thin TTY front-end that
771
+ comes and goes across detach and reattach.
325
772
 
326
773
  ```
327
774
  Client (foreground, owns the TTY) Server (daemon, owns the PTYs)
328
- ├─ STDIN in raw mode + alt screen Application (event loop, lifecycle)
329
- ├─ SIGWINCH → RESIZE frame ├─ Session ─ Window ─ Pane[ ] ─ Terminal + PTYProcess
330
- │ │ └─ Drawer ─ Pane
775
+ ├─ STDIN raw mode + alternate screen Application (event loop, lifecycle)
776
+ ├─ SIGWINCH → RESIZE frame ├─ Session ─ Window ─ Pane[] ─ Terminal + PTYProcess
777
+ ├─ WidthProbe (DSR-CPR glyph measurement) │ └─ Drawer ─ Pane
331
778
  └─ Protocol ├─ Renderer – diff-emits ANSI as OUTPUT frames
332
- ◄── OUTPUT bytes ──── Renderer ◄────────────┤ InputHandler – normal/passthrough mode state machine
333
- ──── INPUT bytes ───► InputHandler ├─ CommandDispatcher – parses ":"-prefixed commands
779
+ ◄── OUTPUT bytes ──── Renderer ◄────────────┤ InputHandler – normal/passthrough state machine
780
+ ──── INPUT bytes ───► InputHandler ├─ CommandDispatcher – ":"-prefixed commands
334
781
  ──── HELLO/RESIZE ──► apply_size ├─ LayoutManager – pure (layout, count, area) → [Rect]
335
782
  ◄── BYE ───────────── disconnect_client ├─ UNIXServer (TTY socket, one client at a time)
336
783
  └─ UNIXServer (.ctrl.sock, many NDJSON clients)
337
784
  ```
338
785
 
339
- Frames are length-prefixed (`[1-byte type][4-byte BE length][payload]`):
340
- `H` hello, `I` input, `R` resize, `B` bye, `O` output.
341
-
342
- A second listener at `~/.muxr/sockets/<name>.ctrl.sock` accepts
343
- multiple concurrent NDJSON clients for the MCP control surface (see
344
- above). The two sockets are independent — programmatic clients never
345
- count as "attached", so they don't lock out the human's TTY client.
346
-
347
- The server's event loop is single-threaded `IO.select` over the
348
- listening sockets, the attached client (when present), every pane PTY,
349
- the drawer PTY, and every connected control client. A single
350
- background thread polls each pane's foreground process group every
351
- 750ms (`/proc/<pid>/stat` on Linux, `ps -o tpgid=,pgid=` on macOS) so
352
- the `· cmd` annotation in the pane title can refresh without blocking
353
- the render loop. Everything else stays on the main thread. Layouts
354
- are pure — `LayoutManager` has no mutable state, so the renderer
355
- recomputes geometry on every tick after a resize or pane add/remove
356
- without bookkeeping.
357
-
358
- `d` (normal) / `C-a d` (passthrough) detaches the client but leaves
359
- the server (and its shells) running; reattaching gives you back the
360
- same panes with their full history. `q` / `C-a q` / `:quit` flash
361
- `kill session? (y/n)` in the status bar and only tear the server down
362
- on `y` — there is no "kill without confirm" keybinding by design.
363
-
364
- The drawer's PTY is **never torn down** when the drawer is hidden — its
365
- shell process keeps running so the next toggle restores the previous
366
- session. Its initial working directory is inherited from whatever pane
367
- was focused when the drawer was first created; only `drawer reset` kills
368
- the PTY.
369
-
370
- The per-pane `Terminal` is a real VT100 emulator (cursor movement, SGR
371
- including 256-color/truecolor and underline subparameters, erase/insert/
372
- delete, autowrap, scroll regions). Scrollback is composited into the
373
- visible grid through a view-offset that auto-tracks new rows while
374
- scrolled back, so reviewed content stays frozen. Plain-text `http`/
375
- `https`/`ftp` URLs that wrap across rows are re-stamped with matching
376
- OSC 8 hyperlink ids after each feed, so terminals like Ghostty, iTerm2,
377
- kitty, and WezTerm merge the wrapped halves back into one clickable
378
- link (program-emitted OSC 8 payloads are left untouched).
379
-
380
- ## Session persistence
381
-
382
- Sessions live in `~/.muxr/sessions/<name>.json`:
786
+ Frames are length-prefixed — `[1-byte type][4-byte BE length][payload]` —
787
+ with types `H` hello, `I` input, `R` resize, `B` bye, `O` output. The HELLO
788
+ payload carries the terminal size plus the width-probe verdict.
383
789
 
384
- ```json
385
- {
386
- "name": "default",
387
- "layout": "tall",
388
- "focused_index": 0,
389
- "master_index": 0,
390
- "panes": [
391
- {"id": "a3f9b2", "cwd": "/home/me/code", "private": false},
392
- {"id": "c2e810", "cwd": "/tmp", "private": true}
393
- ],
394
- "drawer": {"visible": true, "cwd": "/home/me/code"}
395
- }
396
- ```
397
-
398
- Pane ids and the private flag are persisted, so the same ids survive
399
- cold-restart from the JSON snapshot and a pane that was marked private
400
- stays private.
401
-
402
- The JSON file is mainly a **cold-storage fallback**. Between detaches the
403
- live session lives inside the running server process, so `d` (normal) /
404
- `C-a d` (passthrough) then `bin/muxr <name>` reattaches to the exact
405
- same shells with their full history. The JSON only matters once the
406
- server is gone (after `q` / `C-a q` or a reboot): re-launching
407
- `muxr <name>` rebuilds pane and drawer shells
408
- using the saved working directories. Shell command history within those
409
- panes is **not** persisted — that's the job of your shell's own history
410
- file. Run `:save` from inside muxr to write the snapshot.
790
+ The event loop is a single-threaded `IO.select` over both listeners, the
791
+ attached client, every pane PTY, the drawer PTY, and every connected control
792
+ client. The only off-main-thread work is the foreground-command poller. The
793
+ renderer diffs each composed frame against the last and emits only the cells
794
+ that changed, so a busy pane costs a handful of bytes per tick rather than a
795
+ full repaint — and when no client is attached, rendering is skipped entirely
796
+ while PTY data is still drained, so grids stay current for the next attach.
411
797
 
412
798
  ## Development
413
799
 
414
800
  ```bash
415
- bundle install # only minitest and rake
416
- rake test # full suite (200+ unit tests)
801
+ bundle install # minitest and rake only
802
+ rake test # full suite
417
803
 
418
- # Run a single file or test
419
- ruby -Ilib -Itest test/test_layout_manager.rb
804
+ ruby -Ilib -Itest test/test_layout_manager.rb # one file
420
805
  ruby -Ilib -Itest test/test_terminal.rb -n test_csi_cursor_position
421
806
  ```
422
807
 
423
- Tests cover the layout algorithms (including spatial neighbor lookup
424
- for `hjkl`), drawer state machine, window pane ordering, session JSON
425
- round-trip, the client/server framing protocol, the input-handler
426
- state machine (normal/passthrough mode transitions, scrollback,
427
- selection), foreground-command parsing (Linux `/proc` stat format and
428
- shell-filter rules), the renderer's diff-emit, and the VT100
429
- emulator's cursor movement, SGR (including colon-subparameter and
430
- underline-color forms), erase, scroll-region, and autowrap handling.
431
- PTY-dependent code paths are exercised via dependency injection so
432
- tests don't spawn shells.
808
+ The suite is 600+ tests covering the layout algorithms (including spatial
809
+ neighbour lookup), the input-handler state machine, the drawer, window pane
810
+ ordering, session JSON round-trips, the client/server framing protocol, the
811
+ control server and MCP bridge, pane mirroring and fd handoff, the width probe,
812
+ the renderer's diff-emit, scrollback storage, and the VT100 emulator's cursor
813
+ movement, SGR, erase, scroll-region, autowrap, alternate-screen, and snapshot
814
+ round-trip behaviour.
815
+ PTY-spawning code paths are dependency-injected, so tests never spawn a shell.
433
816
 
434
- On-disk layout:
817
+ ### Regenerating the screenshots
435
818
 
436
- ```
437
- ~/.muxr/
438
- ├─ sessions/<name>.json structural snapshot written by `:save`
439
- ├─ sockets/<name>.sock TTY client listener (auto-managed)
440
- ├─ sockets/<name>.ctrl.sock MCP control listener (auto-managed)
441
- └─ logs/<name>.log server stdout/stderr
442
- ```
443
-
444
- ### Regenerating the README screenshots
445
-
446
- The PNGs under `docs/screenshots/` are produced by [`vhs`](https://github.com/charmbracelet/vhs)
819
+ Every image in this README is produced by [`vhs`](https://github.com/charmbracelet/vhs)
447
820
  driving muxr itself — one `.tape` file per screenshot under
448
- `docs/screenshots/tapes/`. After a UI change, refresh them with:
821
+ `docs/screenshots/tapes/`:
449
822
 
450
823
  ```bash
451
824
  brew install vhs # one-time
452
- docs/screenshots/tapes/regenerate.sh # renders all six
825
+ docs/screenshots/tapes/regenerate.sh # re-renders everything
453
826
  ```
454
827
 
455
- Each tape spawns a throwaway `shot` session, populates one or more panes
456
- with `ls`/`git log`/`wc` output, drives the feature being shown (layout,
457
- drawer, scrollback search, selection), and writes a single PNG via
458
- `Screenshot`. Tweak the tape if the keybindings or status bar change.
828
+ Each tape spawns a throwaway `shot` session, populates panes with real output,
829
+ drives the feature being shown, and writes a single PNG. Tapes whose names
830
+ start with `_` are shared fragments pulled in with `Source`.
459
831
 
460
832
  ## Contributing
461
833
 
462
- Contributions are welcome from anyone, with one requirement: **the code
463
- must be generated by a frontier LLM** (e.g. Claude, GPT, Gemini at their
464
- current top-tier model). Hand-written patches will not be accepted.
834
+ Contributions are welcome from anyone, with one requirement: **the code must
835
+ be generated by a frontier LLM** (Claude, GPT, Gemini at their current
836
+ top-tier model). Hand-written patches will not be accepted.
465
837
 
466
838
  When you open a PR, please:
467
839
 
468
- - State which model produced the change in the PR description.
469
- - Include the prompt(s) you used, or a short summary of the conversation
470
- that produced the diff.
471
- - Drive the model yourself — review, push back, iterate. You are
472
- responsible for the patch: it should pass `rake test`, follow the
473
- conventions in `CLAUDE.md`, and not regress existing behavior.
840
+ - State which model produced the change.
841
+ - Include the prompt(s) you used, or a short summary of the conversation that
842
+ produced the diff.
843
+ - Drive the model yourself — review, push back, iterate. You are responsible
844
+ for the patch: it should pass `rake test`, follow the conventions in
845
+ `CLAUDE.md`, and not regress existing behaviour.
846
+
847
+ Bug reports, feature requests, and design discussion in issues are welcome
848
+ regardless of how they are written.
849
+
850
+ ## License
474
851
 
475
- Bug reports, feature requests, and design discussion in issues are
476
- welcome regardless of how they're written.
852
+ MIT — see [LICENSE.txt](LICENSE.txt).