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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
4
- data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
3
+ metadata.gz: 8ef82a33d0eab76d850265772196a2d405e7e03b1161496973f4e88369b0e628
4
+ data.tar.gz: 86a621eaef858c02317431a57ac109dea309c3f2f450f0f28b2d30bd71f4d145
5
5
  SHA512:
6
- metadata.gz: 9139072cdc6e55e0ab94c7144d492858cdb3fa75bf59f5009b925d7f4e6659d8228f64987aba7c08fdff119dbcbb480511969cc64c0904e96a3ae95cb8456ddc
7
- data.tar.gz: 77028f21adf1d5a7710f952af3dfc0621223b4aa0cbb70ff15436c54e797d8d3809885b50d31a71c5f417afaae2bb153bbf149379110813beeb23ba716cefe25
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.4...HEAD
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
- #1 ★ (tall) ──────────────┬ #2 ──────────────────────────┐
11
- │ master pane │ stacked slave pane
12
-
13
- ├──────────────────────────────┤
14
- stacked slave pane
15
-
16
- └───────────────────────────┴──────────────────────────────┘
17
- ┌ Drawer ─────────────────────────────────────────────────┐
18
- │ persistent overlay shell, opens from the bottom
19
- └─────────────────────────────────────────────────────────┘
20
- [default] panes:3 layout:tall focused:#1 drawer:shown muxr ^a ?
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
  ![drawer overlay](docs/screenshots/04-drawer.png)
43
54
 
44
55
  ## Install / run
45
56
 
46
57
  ```bash
47
- git clone https://github.com/roelbondoc/muxr
48
- cd muxr
49
- bin/muxr # attach the "default" session (auto-spawn if needed)
50
- bin/muxr work # attach (or start) a named session
51
- bin/muxr --list # list saved sessions and exit
52
- bin/muxr --help
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
- `bin/muxr` is the client. The first invocation for a session daemonizes a
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. `Ctrl-a d` detaches the client and leaves the server (and every
61
- shell it owns) running, so reattaching gives you back the exact same panes
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
- ## Keybindings (Ctrl-a prefix)
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 k` | close focused pane (or hide drawer) |
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. `C-a [` enters
87
- scrollback with vi-style navigation; the status bar shows a key hint and
88
- the pane title gains `[scrollback N/M]`.
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 live view |
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 exit to live shell |
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 drops you straight back
119
- to the live shell. `C-a ]` writes the yank buffer back into the focused
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 – Ctrl-a state machine
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 └─ UNIXServer listener (one client at a time)
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
- The server's event loop is single-threaded `IO.select` over the listening
157
- socket, the attached client (when present), every pane PTY, and the
158
- drawer PTY. Layouts are pure`LayoutManager` has no mutable state, so
159
- the renderer recomputes geometry on every tick after a resize or
160
- pane add/remove without bookkeeping.
161
-
162
- `Ctrl-a d` detaches the client but leaves the server (and its shells)
163
- running; reattaching gives you back the same panes with their full
164
- history. `Ctrl-a q` and `:quit` flash `kill session? (y/n)` in the status
165
- bar and only tear the server down on `y` — there is no "kill without
166
- confirm" keybinding by design.
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 independentprogrammatic 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": [{"cwd": "/home/me/code"}, {"cwd": "/tmp"}],
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 `Ctrl-a d` then
197
- `bin/muxr <name>` reattaches to the exact same shells with their full
198
- history. The JSON only matters once the server is gone (after `Ctrl-a q`
199
- or a reboot): re-launching `muxr <name>` rebuilds pane and drawer shells
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 (100+ unit tests)
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, drawer state machine, window pane
216
- ordering, session JSON round-trip, the client/server framing protocol,
217
- the input-handler state machine (including scrollback and selection
218
- modes), the renderer's diff-emit, and the VT100 emulator's cursor
219
- movement, SGR (including colon-subparameter and underline-color forms),
220
- erase, scroll-region, and autowrap handling. PTY-dependent code paths
221
- are exercised via dependency injection so tests don't spawn shells.
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 structural snapshot written by `:save`
228
- ├─ sockets/<name>.sock server's Unix listener (auto-managed)
229
- └─ logs/<name>.log server stdout/stderr
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)