muxr 0.1.4 → 0.1.6
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 +111 -1
- data/README.md +210 -65
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +276 -26
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +133 -27
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +60 -3
- data/lib/muxr/renderer.rb +145 -33
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +81 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +2 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ef82a33d0eab76d850265772196a2d405e7e03b1161496973f4e88369b0e628
|
|
4
|
+
data.tar.gz: 86a621eaef858c02317431a57ac109dea309c3f2f450f0f28b2d30bd71f4d145
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0958e09357a6a80965128c0b90e7348a86d8b1bddb63b6891718a34a01703a3bb8d8b8281b9e8c0e2094b8824c3cc64c2b255bbeab0919531848ee46e39e1180'
|
|
7
|
+
data.tar.gz: ab9d8f51cfa56e0e550bd9b608cebaa6a85fcd80871f1018917ef498efbca89f0c8a1a0885bdafafcf2fc1e6a2922b13c977e2a13f98561acd41e21b38c74c11
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,115 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.6] - 2026-05-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Vim-style `:normal` mode as the default startup state. Single keys
|
|
13
|
+
(hjkl, c, K, t/g/m, s, etc.) act directly without the Ctrl-a prefix;
|
|
14
|
+
`i` drops into the historical `:passthrough` mode and `Ctrl-a Esc`
|
|
15
|
+
returns to normal.
|
|
16
|
+
- Spatial hjkl navigation via a new `LayoutManager.neighbor` that
|
|
17
|
+
computes pane adjacency from layout rects, so focus moves the way the
|
|
18
|
+
eye expects in tall and grid layouts (monocle falls back to linear).
|
|
19
|
+
- `[MODE]` chip rendered in the top-right corner of the focused
|
|
20
|
+
container, plus a per-mode color palette on the focused pane border
|
|
21
|
+
and status chip (cyan normal, green passthrough, orange scrollback,
|
|
22
|
+
magenta selection, yellow command, red quit-confirm, blue help). The
|
|
23
|
+
`:prefix` substate shares the passthrough green so the border doesn't
|
|
24
|
+
flicker when Ctrl-a is pressed.
|
|
25
|
+
- Foreground-command annotation in pane titles. A 750ms background
|
|
26
|
+
poller (`Application#start_foreground_poller`) walks each pane's
|
|
27
|
+
foreground process group and stamps the name onto
|
|
28
|
+
`pane.foreground_command`; titles now read `#1 abc123 · npm test`
|
|
29
|
+
when the shell isn't itself in the foreground. Lookup goes through
|
|
30
|
+
`/proc/<pid>/stat` on Linux and `ps` on macOS, off the event loop.
|
|
31
|
+
- OSC 8 hyperlink passthrough. The Terminal buffers OSC payloads,
|
|
32
|
+
extracts OSC 8 link bodies (interned per terminal), and stamps the
|
|
33
|
+
active link onto every cell. The Renderer carries hyperlink through
|
|
34
|
+
its Cell struct and wraps each contiguous run with one open/close
|
|
35
|
+
pair, so Ghostty et al. treat a wrapped URL as one clickable link.
|
|
36
|
+
- Space as a thumb-friendly alias for `v` in selection mode (toggle
|
|
37
|
+
linear anchor).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- Focused pane title now shows the mode label
|
|
41
|
+
(`[NORMAL]`/`[PASS]`/etc.) instead of the redundant layout name —
|
|
42
|
+
the status bar already shows the active layout.
|
|
43
|
+
- `[MODE]` chip moved out of the title and into the top-right corner
|
|
44
|
+
of the focused container, freeing the left-side title for the
|
|
45
|
+
foreground command.
|
|
46
|
+
- Dropped the old `space → page-down` mapping in scrollback so space
|
|
47
|
+
is available for the new selection-mode alias.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- Exiting scrollback now restores the previous base mode (normal or
|
|
51
|
+
passthrough) instead of always landing in normal. A
|
|
52
|
+
passthrough → scrollback → exit round-trip now leaves you back in
|
|
53
|
+
passthrough.
|
|
54
|
+
|
|
55
|
+
## [0.1.5] - 2026-05-13
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- MCP (Model Context Protocol) integration so Claude Code can drive a
|
|
59
|
+
muxr session as a tool. A second listener at
|
|
60
|
+
`~/.muxr/sockets/<name>.ctrl.sock` accepts multiple concurrent NDJSON
|
|
61
|
+
clients and exposes read-only and mutating methods over a small
|
|
62
|
+
JSON-RPC surface (`session.get`, `panes.list`, `pane.read`,
|
|
63
|
+
`pane.send_input`, `pane.run`, `pane.subscribe`, `layout.set`,
|
|
64
|
+
`drawer.*`, etc.). The control socket does not interfere with TTY
|
|
65
|
+
attach — programmatic clients never count as "attached", so a Claude
|
|
66
|
+
session and a human can use the multiplexer concurrently.
|
|
67
|
+
- `pane.run` waits for the PTY to go idle before responding. Sends the
|
|
68
|
+
input, polls for output, and returns once no bytes have arrived for
|
|
69
|
+
`idle_ms` (default 500). Server-side idle detection avoids the
|
|
70
|
+
send-then-poll race that plagues naive client-side automation.
|
|
71
|
+
- Stable per-pane ids: every pane carries a 6-hex `SecureRandom` id that
|
|
72
|
+
survives splits, kills, promote_to_master, detach/reattach, and
|
|
73
|
+
cold-restart from the session JSON. The status bar now reads
|
|
74
|
+
`#1 a3f9b2` so users see both the slot (positional, what `Ctrl-a 1`
|
|
75
|
+
targets) and the id (stable, what the MCP client should reference).
|
|
76
|
+
- `bin/muxr-mcp` — standalone MCP-over-stdio bridge that translates
|
|
77
|
+
Claude Code tool calls into NDJSON requests on the control socket.
|
|
78
|
+
Auto-detects the target session from `MUXR_CONTROL_SOCKET` or
|
|
79
|
+
`MUXR_SESSION` env vars.
|
|
80
|
+
- `Ctrl-a C` (also `:claude`) opens a drawer whose shell is `claude`,
|
|
81
|
+
with `MUXR_SESSION`, `MUXR_CONTROL_SOCKET`, `MUXR_FOCUSED_PANE`, and
|
|
82
|
+
`MUXR_DRAWER_SELF=1` injected into its environment. The bridge picks
|
|
83
|
+
those up automatically; the human gets a Quake-style Claude Code
|
|
84
|
+
overlay that already knows what session it's in. The
|
|
85
|
+
`MUXR_DRAWER_SELF` guard makes the bridge refuse `drawer.*` methods
|
|
86
|
+
so a claude drawer can't recurse into its own PTY.
|
|
87
|
+
- Private panes (`Ctrl-a P` / `:private`) hide a pane from programmatic
|
|
88
|
+
callers: `panes.list` strips cwd/rows/cols, and `pane.read`,
|
|
89
|
+
`pane.send_input`, `pane.run`, `pane.subscribe`, and `pane.kill`
|
|
90
|
+
refuse with an error message pointing the human at `Ctrl-a P` to
|
|
91
|
+
expose it. The flag is persisted in session JSON, shown as `[P]` in
|
|
92
|
+
the status bar, and intentionally one-way — there is no control
|
|
93
|
+
method to flip it.
|
|
94
|
+
- Named keys on `pane.send_input`, `pane.run`, and `drawer.send_input`.
|
|
95
|
+
A `keys` array accepts vim-style `<name>` tokens (`<esc>`, `<c-c>`,
|
|
96
|
+
`<cr>`, arrows, etc.) interleaved with literal text, so MCP callers
|
|
97
|
+
no longer have to remember that Escape is `"\e"` and Ctrl-C is
|
|
98
|
+
`"\x03"`. Bracketed-paste wrapping still applies to literal segments
|
|
99
|
+
only.
|
|
100
|
+
- Skill bundle at `skills/muxr-control/SKILL.md` teaching Claude how to
|
|
101
|
+
drive muxr via the MCP. Installable via `muxr --install-skill`, which
|
|
102
|
+
copies the skill into `~/.claude/skills/muxr-control` (survives
|
|
103
|
+
`gem update`) and prints the `claude mcp add` registration line.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
- Honor DSR cursor-position queries (`\e[5n`, `\e[6n`). The Terminal
|
|
107
|
+
emulator now buffers a `\e[0n` OK reply or a `\e[<row>;<col>R` CPR
|
|
108
|
+
reply into a pending-replies queue; the Pane drains it back through
|
|
109
|
+
the PTY input side after each feed. AWS CLI and other programs that
|
|
110
|
+
probe geometry this way no longer log "your terminal doesn't support
|
|
111
|
+
cursor position requests (CPR)" and fall back to a degraded mode.
|
|
112
|
+
- Drawer height now has a floor of 16 rows. The old 35%-of-screen rule
|
|
113
|
+
degraded badly on short terminals — a 24-row terminal gave only ~8
|
|
114
|
+
rows of drawer, barely enough for one prompt and a few lines of
|
|
115
|
+
output. The 35% growth still applies above the floor, so tall
|
|
116
|
+
terminals are unaffected.
|
|
117
|
+
|
|
9
118
|
## [0.1.4] - 2026-05-13
|
|
10
119
|
|
|
11
120
|
### Fixed
|
|
@@ -108,7 +217,8 @@ Initial release.
|
|
|
108
217
|
boundaries.
|
|
109
218
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
110
219
|
|
|
111
|
-
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.
|
|
220
|
+
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.5...HEAD
|
|
221
|
+
[0.1.5]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.5
|
|
112
222
|
[0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
|
|
113
223
|
[0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
|
|
114
224
|
[0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
|
data/README.md
CHANGED
|
@@ -7,22 +7,33 @@ like tiling-window-manager clients — you never resize them by hand;
|
|
|
7
7
|
the active layout decides geometry.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
|
|
11
|
-
│ master pane
|
|
12
|
-
│
|
|
13
|
-
│
|
|
14
|
-
│
|
|
15
|
-
│
|
|
16
|
-
|
|
17
|
-
┌ Drawer
|
|
18
|
-
│ persistent overlay shell, opens from the bottom
|
|
19
|
-
|
|
20
|
-
[default] panes:3 layout:tall focused:#1 drawer:shown
|
|
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 ?
|
|
21
21
|
```
|
|
22
22
|
|
|
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).
|
|
33
|
+
|
|
23
34
|
## Screenshots
|
|
24
35
|
|
|
25
|
-
The three built-in layouts (cycle with `C-a Tab`):
|
|
36
|
+
The three built-in layouts (pick directly with `t`/`g`/`m` in normal mode, or cycle with `Tab` / `C-a Tab`):
|
|
26
37
|
|
|
27
38
|
<table>
|
|
28
39
|
<tr>
|
|
@@ -37,42 +48,98 @@ The three built-in layouts (cycle with `C-a Tab`):
|
|
|
37
48
|
</tr>
|
|
38
49
|
</table>
|
|
39
50
|
|
|
40
|
-
The Quake-style drawer overlay (`C-a ~`):
|
|
51
|
+
The Quake-style drawer overlay (`~` in normal mode, `C-a ~` in passthrough):
|
|
41
52
|
|
|
42
53
|

|
|
43
54
|
|
|
44
55
|
## Install / run
|
|
45
56
|
|
|
46
57
|
```bash
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
gem install muxr
|
|
59
|
+
muxr # attach the "default" session (auto-spawn if needed)
|
|
60
|
+
muxr work # attach (or start) a named session
|
|
61
|
+
muxr --list # list running sessions and exit
|
|
62
|
+
muxr --install-skill # install the MCP skill into ~/.claude/skills
|
|
63
|
+
muxr --help
|
|
53
64
|
```
|
|
54
65
|
|
|
55
66
|
Requires **Ruby ≥ 3.4**. No runtime gems — just `PTY`, `IO.console`, `JSON`,
|
|
56
67
|
`Socket`, and `FileUtils` from stdlib.
|
|
57
68
|
|
|
58
|
-
`
|
|
69
|
+
`muxr` is the client. The first invocation for a session daemonizes a
|
|
59
70
|
server in the background; subsequent invocations attach to it over a Unix
|
|
60
|
-
socket. `
|
|
61
|
-
shell it owns) running, so reattaching
|
|
62
|
-
with their full history.
|
|
71
|
+
socket. `d` (normal mode) / `C-a d` (passthrough) detaches the client
|
|
72
|
+
and leaves the server (and every shell it owns) running, so reattaching
|
|
73
|
+
gives you back the exact same panes with their full history.
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
### From source
|
|
76
|
+
|
|
77
|
+
To run the latest unreleased code or hack on muxr locally, clone the repo
|
|
78
|
+
and use `bin/muxr` directly — it puts `lib/` on `$LOAD_PATH` itself:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git clone https://github.com/roelbondoc/muxr
|
|
82
|
+
cd muxr
|
|
83
|
+
bin/muxr # same flags as the installed `muxr` executable
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Modes
|
|
87
|
+
|
|
88
|
+
muxr has two top-level input modes, modeled on vim:
|
|
89
|
+
|
|
90
|
+
- **Normal** (default at startup) — single keys act on the multiplexer.
|
|
91
|
+
`hjkl` moves focus between panes, `c`/`K` create/kill panes,
|
|
92
|
+
`t`/`g`/`m` set the layout, etc. No prefix needed.
|
|
93
|
+
- **Passthrough** (entered with `i`) — every keystroke is forwarded to
|
|
94
|
+
the focused pane, exactly like a regular terminal. muxr commands are
|
|
95
|
+
reached via the historical `Ctrl-a` prefix. `Ctrl-a Esc` returns to
|
|
96
|
+
normal mode.
|
|
97
|
+
|
|
98
|
+
The active mode appears as a `[MODE]` chip in the top-right corner of
|
|
99
|
+
the focused pane (and the leftmost slot of the status bar). The
|
|
100
|
+
focused pane's border is colored by mode — cyan for normal, green for
|
|
101
|
+
passthrough, orange for scrollback, magenta for selection, yellow for
|
|
102
|
+
the command prompt, red during the kill-session confirmation, blue
|
|
103
|
+
while help is open. Unfocused panes always render with the grey
|
|
104
|
+
unfocused border, regardless of mode.
|
|
105
|
+
|
|
106
|
+
### Normal mode
|
|
107
|
+
|
|
108
|
+
| Keys | Action |
|
|
109
|
+
|----------------------|-----------------------------------------------------|
|
|
110
|
+
| `h` / `j` / `k` / `l`| focus pane left / down / up / right (spatial) |
|
|
111
|
+
| `i` | drop into passthrough mode |
|
|
112
|
+
| `c` / `K` | new / close focused pane |
|
|
113
|
+
| `t` / `g` / `m` | layout: tall / grid / monocle |
|
|
114
|
+
| `Tab` / `Enter` | cycle layout / promote focused to master |
|
|
115
|
+
| `a` / `1` … `9` | toggle last pane / jump to pane by number |
|
|
116
|
+
| `s` | enter scrollback / copy-mode |
|
|
117
|
+
| `~` / `C` / `P` | drawer / Claude drawer / toggle private flag |
|
|
118
|
+
| `]` | paste internal yank buffer into focused pane |
|
|
119
|
+
| `:` / `?` | command prompt / help |
|
|
120
|
+
| `d` / `q` | detach / kill session (asks `y/n`) |
|
|
121
|
+
|
|
122
|
+
`h`/`j`/`k`/`l` does true spatial navigation — it inspects the current
|
|
123
|
+
layout's rectangles and picks the closest neighbor in the requested
|
|
124
|
+
direction. In monocle (where every pane occupies the full area) it
|
|
125
|
+
falls back to linear next/previous so the keys still do something
|
|
126
|
+
useful.
|
|
127
|
+
|
|
128
|
+
### Passthrough mode (`Ctrl-a` prefix)
|
|
65
129
|
|
|
66
130
|
| Keys | Action |
|
|
67
131
|
|----------------|---------------------------------------------------------|
|
|
132
|
+
| `C-a Esc` | return to normal mode |
|
|
68
133
|
| `C-a c` | new pane |
|
|
69
|
-
| `C-a n` / `p` | focus next / previous pane
|
|
134
|
+
| `C-a n` / `p` | focus next / previous pane (linear) |
|
|
70
135
|
| `C-a a` | toggle last (previously focused) pane |
|
|
71
136
|
| `C-a 1` … `9` | jump to pane by its label |
|
|
72
|
-
| `C-a
|
|
137
|
+
| `C-a K` | close focused pane (or hide drawer) |
|
|
73
138
|
| `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`) |
|
|
74
139
|
| `C-a Enter` | promote focused pane to master |
|
|
75
|
-
| `C-a ~` | toggle drawer
|
|
140
|
+
| `C-a ~` | toggle drawer (shell) |
|
|
141
|
+
| `C-a C` | toggle Claude Code drawer (MCP-aware) |
|
|
142
|
+
| `C-a P` | toggle private flag on focused pane (hides from MCP) |
|
|
76
143
|
| `C-a [` | enter scrollback / copy-mode |
|
|
77
144
|
| `C-a ]` | paste internal yank buffer into focused pane |
|
|
78
145
|
| `C-a d` | detach (server keeps running) |
|
|
@@ -83,9 +150,10 @@ with their full history.
|
|
|
83
150
|
|
|
84
151
|
### Scrollback and copy-mode
|
|
85
152
|
|
|
86
|
-
Each pane keeps a bounded (5000-row) scrollback ring. `
|
|
87
|
-
|
|
88
|
-
the pane title gains
|
|
153
|
+
Each pane keeps a bounded (5000-row) scrollback ring. `s` in normal
|
|
154
|
+
mode (or `C-a [` in passthrough) enters scrollback with vi-style
|
|
155
|
+
navigation; the status bar shows a key hint and the pane title gains
|
|
156
|
+
`[scrollback N/M]`.
|
|
89
157
|
|
|
90
158
|
| Keys | Action |
|
|
91
159
|
|-------------------------|-------------------------------------|
|
|
@@ -93,7 +161,7 @@ the pane title gains `[scrollback N/M]`.
|
|
|
93
161
|
| `d` / `u` (or `C-d`/`C-u`) | half page |
|
|
94
162
|
| `f` / Space (or `C-f`/`C-b`) | full page |
|
|
95
163
|
| `g` / `G` | top / bottom |
|
|
96
|
-
| `q` / `Esc` / `C-c` | exit back to
|
|
164
|
+
| `q` / `Esc` / `C-c` | exit back to normal mode |
|
|
97
165
|
|
|
98
166
|
Press `v` inside scrollback to enter a movable-cursor selection mode.
|
|
99
167
|
Vim-style motions are supported:
|
|
@@ -109,21 +177,23 @@ Vim-style motions are supported:
|
|
|
109
177
|
| `H` / `M` / `L` | top / middle / bottom of viewport |
|
|
110
178
|
| `C-d`/`C-u`, `C-f`/`C-b`, Space | half / full page |
|
|
111
179
|
| `v` / `C-v` | anchor char / block selection (toggle) |
|
|
112
|
-
| `y` or Enter | yank and
|
|
180
|
+
| `y` or Enter | yank and return to normal mode |
|
|
113
181
|
| `q` / `Esc` / `C-c` | cancel back to scrollback |
|
|
114
182
|
|
|
115
183
|
`v` and `C-v` toggle between character and block (rectangular) selection
|
|
116
184
|
— switching between the two preserves the anchor. `y` or Enter yanks the
|
|
117
185
|
selection into an internal buffer, pipes it to `pbcopy` in the background
|
|
118
|
-
(silent no-op when `pbcopy` is unavailable), and
|
|
119
|
-
|
|
120
|
-
pane.
|
|
186
|
+
(silent no-op when `pbcopy` is unavailable), and returns to normal mode.
|
|
187
|
+
`]` (normal) / `C-a ]` (passthrough) writes the yank buffer back into the
|
|
188
|
+
focused pane.
|
|
121
189
|
|
|
122
|
-
## Commands (typed after `C-a :`)
|
|
190
|
+
## Commands (typed after `:` in normal mode, or `C-a :` in passthrough)
|
|
123
191
|
|
|
124
192
|
```
|
|
125
193
|
layout {tall|grid|monocle} # also: layout (no arg) → cycle
|
|
126
194
|
drawer {toggle|show|hide|reset}
|
|
195
|
+
claude # toggle the Claude Code drawer
|
|
196
|
+
private # toggle private flag on focused pane
|
|
127
197
|
save # persist session to ~/.muxr/sessions/<name>.json
|
|
128
198
|
restore # show path to saved session
|
|
129
199
|
sessions | ls # list saved sessions
|
|
@@ -131,6 +201,58 @@ new | close | next | prev | master
|
|
|
131
201
|
detach | quit # quit asks for y/n confirmation
|
|
132
202
|
```
|
|
133
203
|
|
|
204
|
+
## MCP control surface
|
|
205
|
+
|
|
206
|
+
muxr exposes a second listener at `~/.muxr/sockets/<name>.ctrl.sock`
|
|
207
|
+
that accepts multiple concurrent NDJSON clients over a small JSON-RPC
|
|
208
|
+
surface (`session.get`, `panes.list`, `pane.read`, `pane.send_input`,
|
|
209
|
+
`pane.run`, `pane.subscribe`, `pane.kill`, `layout.set`, `drawer.*`,
|
|
210
|
+
…). The control socket is independent of TTY attach — programmatic
|
|
211
|
+
clients never count as "attached", so a Claude Code session and a human
|
|
212
|
+
can drive the multiplexer concurrently.
|
|
213
|
+
|
|
214
|
+
`pane.run` waits for the PTY to go idle before responding: it sends the
|
|
215
|
+
input, polls for output, and returns once no bytes have arrived for
|
|
216
|
+
`idle_ms` (default 500). Server-side idle detection avoids the
|
|
217
|
+
send-then-poll race that plagues naive client-side automation.
|
|
218
|
+
|
|
219
|
+
`pane.send_input`, `pane.run`, and `drawer.send_input` accept a `keys`
|
|
220
|
+
array of vim-style `<name>` tokens (`<esc>`, `<c-c>`, `<cr>`, arrows,
|
|
221
|
+
etc.) interleaved with literal text — callers don't have to remember
|
|
222
|
+
that Escape is `"\e"` and Ctrl-C is `"\x03"`. Bracketed-paste wrapping
|
|
223
|
+
still applies to literal segments only.
|
|
224
|
+
|
|
225
|
+
### Claude Code integration
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
muxr --install-skill # copies skills/muxr-control into ~/.claude/skills
|
|
229
|
+
# and prints the `claude mcp add` registration line
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`bin/muxr-mcp` is the standalone MCP-over-stdio bridge that translates
|
|
233
|
+
Claude Code tool calls into NDJSON requests on the control socket. It
|
|
234
|
+
auto-detects the target session from `MUXR_CONTROL_SOCKET` or
|
|
235
|
+
`MUXR_SESSION` env vars.
|
|
236
|
+
|
|
237
|
+
`C` (normal) / `C-a C` (passthrough) / `:claude` opens a drawer whose shell is `claude`, with
|
|
238
|
+
`MUXR_SESSION`, `MUXR_CONTROL_SOCKET`, `MUXR_FOCUSED_PANE`, and
|
|
239
|
+
`MUXR_DRAWER_SELF=1` injected into its environment. The bridge picks
|
|
240
|
+
those up automatically; you get a Quake-style Claude Code overlay that
|
|
241
|
+
already knows what session it's in. `MUXR_DRAWER_SELF` makes the bridge
|
|
242
|
+
refuse `drawer.*` methods, so a claude drawer can't recurse into its
|
|
243
|
+
own PTY.
|
|
244
|
+
|
|
245
|
+
### Private panes
|
|
246
|
+
|
|
247
|
+
`P` (normal) / `C-a P` (passthrough) / `:private` flips the private flag on the focused pane.
|
|
248
|
+
Private panes are hidden from programmatic callers: `panes.list` strips
|
|
249
|
+
cwd/rows/cols, and `pane.read`, `pane.send_input`, `pane.run`,
|
|
250
|
+
`pane.subscribe`, and `pane.kill` refuse with an error message pointing
|
|
251
|
+
the human at the TTY (`P` / `C-a P`) to expose it. The flag is persisted in session
|
|
252
|
+
JSON and shown as `[P]` in the pane title bar. The MCP surface
|
|
253
|
+
intentionally has no method to flip the flag — only a human at the TTY
|
|
254
|
+
can mark a pane public again.
|
|
255
|
+
|
|
134
256
|
## Architecture
|
|
135
257
|
|
|
136
258
|
muxr runs as **two processes** that talk over a Unix domain socket at
|
|
@@ -144,26 +266,37 @@ Client (foreground, owns the TTY) Server (daemon, owns the PTYs)
|
|
|
144
266
|
├─ SIGWINCH → RESIZE frame ├─ Session ─ Window ─ Pane[ ] ─ Terminal + PTYProcess
|
|
145
267
|
│ │ └─ Drawer ─ Pane
|
|
146
268
|
└─ Protocol ├─ Renderer – diff-emits ANSI as OUTPUT frames
|
|
147
|
-
◄── OUTPUT bytes ──── Renderer ◄────────────┤ InputHandler –
|
|
269
|
+
◄── OUTPUT bytes ──── Renderer ◄────────────┤ InputHandler – normal/passthrough mode state machine
|
|
148
270
|
──── INPUT bytes ───► InputHandler ├─ CommandDispatcher – parses ":"-prefixed commands
|
|
149
271
|
──── HELLO/RESIZE ──► apply_size ├─ LayoutManager – pure (layout, count, area) → [Rect]
|
|
150
|
-
◄── BYE ───────────── disconnect_client
|
|
272
|
+
◄── BYE ───────────── disconnect_client ├─ UNIXServer (TTY socket, one client at a time)
|
|
273
|
+
└─ UNIXServer (.ctrl.sock, many NDJSON clients)
|
|
151
274
|
```
|
|
152
275
|
|
|
153
276
|
Frames are length-prefixed (`[1-byte type][4-byte BE length][payload]`):
|
|
154
277
|
`H` hello, `I` input, `R` resize, `B` bye, `O` output.
|
|
155
278
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
279
|
+
A second listener at `~/.muxr/sockets/<name>.ctrl.sock` accepts
|
|
280
|
+
multiple concurrent NDJSON clients for the MCP control surface (see
|
|
281
|
+
above). The two sockets are independent — programmatic clients never
|
|
282
|
+
count as "attached", so they don't lock out the human's TTY client.
|
|
283
|
+
|
|
284
|
+
The server's event loop is single-threaded `IO.select` over the
|
|
285
|
+
listening sockets, the attached client (when present), every pane PTY,
|
|
286
|
+
the drawer PTY, and every connected control client. A single
|
|
287
|
+
background thread polls each pane's foreground process group every
|
|
288
|
+
750ms (`/proc/<pid>/stat` on Linux, `ps -o tpgid=,pgid=` on macOS) so
|
|
289
|
+
the `· cmd` annotation in the pane title can refresh without blocking
|
|
290
|
+
the render loop. Everything else stays on the main thread. Layouts
|
|
291
|
+
are pure — `LayoutManager` has no mutable state, so the renderer
|
|
292
|
+
recomputes geometry on every tick after a resize or pane add/remove
|
|
293
|
+
without bookkeeping.
|
|
294
|
+
|
|
295
|
+
`d` (normal) / `C-a d` (passthrough) detaches the client but leaves
|
|
296
|
+
the server (and its shells) running; reattaching gives you back the
|
|
297
|
+
same panes with their full history. `q` / `C-a q` / `:quit` flash
|
|
298
|
+
`kill session? (y/n)` in the status bar and only tear the server down
|
|
299
|
+
on `y` — there is no "kill without confirm" keybinding by design.
|
|
167
300
|
|
|
168
301
|
The drawer's PTY is **never torn down** when the drawer is hidden — its
|
|
169
302
|
shell process keeps running so the next toggle restores the previous
|
|
@@ -187,16 +320,24 @@ Sessions live in `~/.muxr/sessions/<name>.json`:
|
|
|
187
320
|
"layout": "tall",
|
|
188
321
|
"focused_index": 0,
|
|
189
322
|
"master_index": 0,
|
|
190
|
-
"panes": [
|
|
323
|
+
"panes": [
|
|
324
|
+
{"id": "a3f9b2", "cwd": "/home/me/code", "private": false},
|
|
325
|
+
{"id": "c2e810", "cwd": "/tmp", "private": true}
|
|
326
|
+
],
|
|
191
327
|
"drawer": {"visible": true, "cwd": "/home/me/code"}
|
|
192
328
|
}
|
|
193
329
|
```
|
|
194
330
|
|
|
331
|
+
Pane ids and the private flag are persisted, so the same ids survive
|
|
332
|
+
cold-restart from the JSON snapshot and a pane that was marked private
|
|
333
|
+
stays private.
|
|
334
|
+
|
|
195
335
|
The JSON file is mainly a **cold-storage fallback**. Between detaches the
|
|
196
|
-
live session lives inside the running server process, so `
|
|
197
|
-
`bin/muxr <name>` reattaches to the exact
|
|
198
|
-
history. The JSON only matters once the
|
|
199
|
-
or a reboot): re-launching
|
|
336
|
+
live session lives inside the running server process, so `d` (normal) /
|
|
337
|
+
`C-a d` (passthrough) then `bin/muxr <name>` reattaches to the exact
|
|
338
|
+
same shells with their full history. The JSON only matters once the
|
|
339
|
+
server is gone (after `q` / `C-a q` or a reboot): re-launching
|
|
340
|
+
`muxr <name>` rebuilds pane and drawer shells
|
|
200
341
|
using the saved working directories. Shell command history within those
|
|
201
342
|
panes is **not** persisted — that's the job of your shell's own history
|
|
202
343
|
file. Run `:save` from inside muxr to write the snapshot.
|
|
@@ -205,28 +346,32 @@ file. Run `:save` from inside muxr to write the snapshot.
|
|
|
205
346
|
|
|
206
347
|
```bash
|
|
207
348
|
bundle install # only minitest and rake
|
|
208
|
-
rake test # full suite (
|
|
349
|
+
rake test # full suite (200+ unit tests)
|
|
209
350
|
|
|
210
351
|
# Run a single file or test
|
|
211
352
|
ruby -Ilib -Itest test/test_layout_manager.rb
|
|
212
353
|
ruby -Ilib -Itest test/test_terminal.rb -n test_csi_cursor_position
|
|
213
354
|
```
|
|
214
355
|
|
|
215
|
-
Tests cover the layout algorithms
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
356
|
+
Tests cover the layout algorithms (including spatial neighbor lookup
|
|
357
|
+
for `hjkl`), drawer state machine, window pane ordering, session JSON
|
|
358
|
+
round-trip, the client/server framing protocol, the input-handler
|
|
359
|
+
state machine (normal/passthrough mode transitions, scrollback,
|
|
360
|
+
selection), foreground-command parsing (Linux `/proc` stat format and
|
|
361
|
+
shell-filter rules), the renderer's diff-emit, and the VT100
|
|
362
|
+
emulator's cursor movement, SGR (including colon-subparameter and
|
|
363
|
+
underline-color forms), erase, scroll-region, and autowrap handling.
|
|
364
|
+
PTY-dependent code paths are exercised via dependency injection so
|
|
365
|
+
tests don't spawn shells.
|
|
222
366
|
|
|
223
367
|
On-disk layout:
|
|
224
368
|
|
|
225
369
|
```
|
|
226
370
|
~/.muxr/
|
|
227
|
-
├─ sessions/<name>.json
|
|
228
|
-
├─ sockets/<name>.sock
|
|
229
|
-
|
|
371
|
+
├─ sessions/<name>.json structural snapshot written by `:save`
|
|
372
|
+
├─ sockets/<name>.sock TTY client listener (auto-managed)
|
|
373
|
+
├─ sockets/<name>.ctrl.sock MCP control listener (auto-managed)
|
|
374
|
+
└─ logs/<name>.log server stdout/stderr
|
|
230
375
|
```
|
|
231
376
|
|
|
232
377
|
## Contributing
|
data/bin/muxr
CHANGED
|
@@ -18,6 +18,65 @@ if ARGV.include?("-l") || ARGV.include?("--list")
|
|
|
18
18
|
exit 0
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
if ARGV.include?("--install-skill")
|
|
22
|
+
# Copies skills/muxr-control into ~/.claude/skills/muxr-control so the
|
|
23
|
+
# claude CLI loads it from anywhere. We *copy* rather than symlink so the
|
|
24
|
+
# install survives a RubyGems upgrade (which deletes the old versioned
|
|
25
|
+
# gem path that a symlink would have pointed at). Idempotent: re-running
|
|
26
|
+
# this after a `gem update muxr` is the supported way to refresh the
|
|
27
|
+
# skill content.
|
|
28
|
+
src = File.expand_path("../skills/muxr-control", __dir__)
|
|
29
|
+
unless File.directory?(src)
|
|
30
|
+
$stderr.puts "muxr: skill source not found at #{src} (was the gem packaged without skills/?)"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
claude_skills = File.expand_path("~/.claude/skills")
|
|
34
|
+
dst = File.join(claude_skills, "muxr-control")
|
|
35
|
+
FileUtils.mkdir_p(claude_skills)
|
|
36
|
+
# Wipe any prior install — symlink (from older dev installs) or directory.
|
|
37
|
+
if File.symlink?(dst) || File.exist?(dst)
|
|
38
|
+
FileUtils.rm_rf(dst)
|
|
39
|
+
end
|
|
40
|
+
FileUtils.mkdir_p(dst)
|
|
41
|
+
FileUtils.cp_r(File.join(src, "."), dst)
|
|
42
|
+
|
|
43
|
+
# Pick the most stable absolute path we can point claude's mcp config at:
|
|
44
|
+
#
|
|
45
|
+
# - Installed gem: $GEM_HOME/bin/muxr-mcp is a wrapper bin stub that
|
|
46
|
+
# survives version upgrades (RubyGems repoints it on `gem update`).
|
|
47
|
+
# Always prefer it when present.
|
|
48
|
+
# - Source checkout: no bin stub exists; fall back to the script that
|
|
49
|
+
# sits next to the running `muxr` binary.
|
|
50
|
+
bin_stub = File.join(Gem.bindir, "muxr-mcp") rescue nil
|
|
51
|
+
bridge = (bin_stub && File.exist?(bin_stub)) ? bin_stub : File.expand_path("muxr-mcp", __dir__)
|
|
52
|
+
puts <<~MSG
|
|
53
|
+
✓ Copied skill to: #{dst}
|
|
54
|
+
(source: #{src})
|
|
55
|
+
|
|
56
|
+
Re-run `muxr --install-skill` after `gem update muxr` to refresh the
|
|
57
|
+
skill contents.
|
|
58
|
+
|
|
59
|
+
Next, register the muxr MCP bridge with Claude Code at USER scope
|
|
60
|
+
(so every claude session sees it, not just ones started from your
|
|
61
|
+
home directory):
|
|
62
|
+
|
|
63
|
+
claude mcp add muxr #{bridge} --scope user
|
|
64
|
+
|
|
65
|
+
If you omit `--scope user`, `claude mcp add` defaults to local scope
|
|
66
|
+
and the MCP is only loaded when claude starts from your current cwd —
|
|
67
|
+
a common first-run gotcha.
|
|
68
|
+
|
|
69
|
+
If your gem bin directory is on $PATH, `muxr-mcp` works as a bare name too:
|
|
70
|
+
|
|
71
|
+
claude mcp add muxr muxr-mcp --scope user
|
|
72
|
+
|
|
73
|
+
Then restart any running Claude Code sessions so they pick up the new
|
|
74
|
+
config, and launch claude from inside a muxr drawer (Ctrl-a C) — the
|
|
75
|
+
bridge auto-detects the session via MUXR_SESSION / MUXR_CONTROL_SOCKET.
|
|
76
|
+
MSG
|
|
77
|
+
exit 0
|
|
78
|
+
end
|
|
79
|
+
|
|
21
80
|
if ARGV.include?("-h") || ARGV.include?("--help")
|
|
22
81
|
puts <<~USAGE
|
|
23
82
|
muxr #{Muxr::VERSION} — a tiling terminal multiplexer
|
|
@@ -27,6 +86,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
|
|
|
27
86
|
muxr <name> attach (or start) the named session
|
|
28
87
|
muxr -s <name> same as above
|
|
29
88
|
muxr --list list running sessions and exit
|
|
89
|
+
muxr --install-skill symlink the MCP skill into ~/.claude/skills and exit
|
|
30
90
|
muxr --version print version and exit
|
|
31
91
|
muxr --help this help
|
|
32
92
|
|
|
@@ -35,6 +95,7 @@ if ARGV.include?("-h") || ARGV.include?("--help")
|
|
|
35
95
|
C-a a toggle last pane C-a 1..9 jump to pane by number
|
|
36
96
|
C-a k close pane C-a Tab cycle layout
|
|
37
97
|
C-a ~ toggle drawer C-a Enter promote to master
|
|
98
|
+
C-a C Claude drawer (MCP-aware; needs `muxr --install-skill` + bridge configured)
|
|
38
99
|
C-a : command prompt C-a ? toggle help
|
|
39
100
|
C-a d detach (server stays running)
|
|
40
101
|
C-a q kill session (with y/n confirmation)
|