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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/README.md
CHANGED
|
@@ -1,476 +1,852 @@
|
|
|
1
|
-
|
|
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
|
+

|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
+

|
|
33
106
|
|
|
34
|
-
##
|
|
107
|
+
## Layouts
|
|
35
108
|
|
|
36
|
-
|
|
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`
|
|
41
|
-
| `wide` | `w`
|
|
42
|
-
| `columns` | `\|` | equal-width full-height vertical strips |
|
|
43
|
-
| `rows` | `-`
|
|
44
|
-
| `grid` | `g`
|
|
45
|
-
| `spiral` | `f`
|
|
46
|
-
| `centered` | `e`
|
|
47
|
-
| `stack` | `S`
|
|
48
|
-
| `monocle` | `m`
|
|
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
|
|
53
|
-
<td align="center"><strong>wide</strong
|
|
54
|
-
<td align="center"><strong>columns</strong
|
|
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/
|
|
58
|
-
<td><img src="docs/screenshots/
|
|
59
|
-
<td><img src="docs/screenshots/
|
|
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
|
|
63
|
-
<td align="center"><strong>grid</strong
|
|
64
|
-
<td align="center"><strong>spiral</strong
|
|
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/
|
|
68
|
-
<td><img src="docs/screenshots/
|
|
69
|
-
<td><img src="docs/screenshots/
|
|
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
|
|
73
|
-
<td align="center"><strong>stack</strong
|
|
74
|
-
<td align="center"><strong>monocle</strong
|
|
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/
|
|
78
|
-
<td><img src="docs/screenshots/
|
|
79
|
-
<td><img src="docs/screenshots/
|
|
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
|
-
|
|
181
|
+
## Reading the screen
|
|
84
182
|
|
|
85
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
592
|
+
Two details make automation over it reliable rather than racy:
|
|
97
593
|
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
and use `bin/muxr` directly — it puts `lib/` on `$LOAD_PATH` itself:
|
|
667
|
+

|
|
122
668
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
681
|
+
`:save` writes a structural snapshot to `~/.muxr/sessions/<name>.json`:
|
|
268
682
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
`
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
717
|
+
## Configuration
|
|
289
718
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
`
|
|
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
|
-
|
|
301
|
-
`
|
|
302
|
-
`
|
|
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
|
-
|
|
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
|
-
`
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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**
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
329
|
-
├─ SIGWINCH → RESIZE frame ├─ Session ─ Window ─ Pane[
|
|
330
|
-
│
|
|
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
|
|
333
|
-
──── INPUT bytes ───► InputHandler ├─ CommandDispatcher –
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
416
|
-
rake test
|
|
801
|
+
bundle install # minitest and rake only
|
|
802
|
+
rake test # full suite
|
|
417
803
|
|
|
418
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
round-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
|
821
|
+
`docs/screenshots/tapes/`:
|
|
449
822
|
|
|
450
823
|
```bash
|
|
451
824
|
brew install vhs # one-time
|
|
452
|
-
docs/screenshots/tapes/regenerate.sh # renders
|
|
825
|
+
docs/screenshots/tapes/regenerate.sh # re-renders everything
|
|
453
826
|
```
|
|
454
827
|
|
|
455
|
-
Each tape spawns a throwaway `shot` session, populates
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
|
469
|
-
- Include the prompt(s) you used, or a short summary of the conversation
|
|
470
|
-
|
|
471
|
-
- Drive the model yourself — review, push back, iterate. You are
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
476
|
-
welcome regardless of how they're written.
|
|
852
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|