muxr 0.1.5 → 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 +46 -0
- data/README.md +210 -65
- data/lib/muxr/application.rb +126 -0
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +131 -27
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +10 -0
- data/lib/muxr/renderer.rb +136 -35
- data/lib/muxr/terminal.rb +44 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- metadata +2 -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,52 @@ 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
|
+
|
|
9
55
|
## [0.1.5] - 2026-05-13
|
|
10
56
|
|
|
11
57
|
### Added
|
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/lib/muxr/application.rb
CHANGED
|
@@ -72,8 +72,15 @@ module Muxr
|
|
|
72
72
|
@control_server = nil
|
|
73
73
|
@paste_buffer = +""
|
|
74
74
|
@last_render_at = nil
|
|
75
|
+
@foreground_poller = nil
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# Interval for the background thread that refreshes each pane's
|
|
79
|
+
# foreground-command label. Picked to feel responsive (a long-running
|
|
80
|
+
# `npm test` shows up within a second of starting) without burning CPU
|
|
81
|
+
# on macOS, where each tick costs a `ps` fork+exec per pane.
|
|
82
|
+
FOREGROUND_POLL_INTERVAL = 0.75
|
|
83
|
+
|
|
77
84
|
attr_reader :paste_buffer
|
|
78
85
|
|
|
79
86
|
def run
|
|
@@ -141,6 +148,60 @@ module Muxr
|
|
|
141
148
|
invalidate
|
|
142
149
|
end
|
|
143
150
|
|
|
151
|
+
# Move focus to the pane spatially adjacent in `direction` (:left/:right/
|
|
152
|
+
# :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
|
|
153
|
+
# layout rects keeps this in sync with whatever the renderer is showing.
|
|
154
|
+
# Monocle has no meaningful direction (every rect is identical) so we
|
|
155
|
+
# fall back to linear nav so hjkl still does something.
|
|
156
|
+
def focus_direction(direction)
|
|
157
|
+
return if @session.window.panes.empty?
|
|
158
|
+
if @session.focus_drawer && @session.drawer&.visible?
|
|
159
|
+
@session.focus_drawer = false
|
|
160
|
+
invalidate
|
|
161
|
+
return
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
win = @session.window
|
|
165
|
+
idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
|
|
166
|
+
if idx.nil? && win.layout == :monocle
|
|
167
|
+
case direction
|
|
168
|
+
when :right, :down then win.focus_next
|
|
169
|
+
when :left, :up then win.focus_prev
|
|
170
|
+
end
|
|
171
|
+
invalidate
|
|
172
|
+
return
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
return unless idx
|
|
176
|
+
win.focus_index(idx)
|
|
177
|
+
invalidate
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Explicit layout set, used by the normal-mode t/g/m bindings and the
|
|
181
|
+
# `:layout <name>` command.
|
|
182
|
+
def set_layout(layout)
|
|
183
|
+
@session.window.set_layout(layout)
|
|
184
|
+
flash("layout: #{@session.window.layout}")
|
|
185
|
+
invalidate
|
|
186
|
+
rescue ArgumentError => e
|
|
187
|
+
flash(e.message)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Bound to `i` in normal mode — drops the user into the historical
|
|
191
|
+
# Ctrl-a-prefixed multiplexer mode.
|
|
192
|
+
def enter_passthrough_mode
|
|
193
|
+
@input.enter_passthrough_mode
|
|
194
|
+
flash("passthrough mode (^a esc to return)")
|
|
195
|
+
invalidate
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Bound to `Ctrl-a Esc` from passthrough — return to normal mode.
|
|
199
|
+
def enter_normal_mode
|
|
200
|
+
@input.enter_normal_mode
|
|
201
|
+
flash("normal mode")
|
|
202
|
+
invalidate
|
|
203
|
+
end
|
|
204
|
+
|
|
144
205
|
def close_focused
|
|
145
206
|
if @session.focus_drawer && @session.drawer&.visible?
|
|
146
207
|
hide_drawer
|
|
@@ -486,6 +547,20 @@ module Muxr
|
|
|
486
547
|
end
|
|
487
548
|
end
|
|
488
549
|
|
|
550
|
+
# Live pane rects for the current layout/size, computed the same way the
|
|
551
|
+
# Renderer does so spatial neighbor lookup matches what the user sees.
|
|
552
|
+
def current_pane_rects
|
|
553
|
+
win = @session.window
|
|
554
|
+
area = LayoutManager::Rect.new(0, 0, @session.width, @session.height - 1)
|
|
555
|
+
LayoutManager.compute(
|
|
556
|
+
win.layout,
|
|
557
|
+
win.panes.length,
|
|
558
|
+
area,
|
|
559
|
+
focused_index: win.focused_index,
|
|
560
|
+
master_index: win.master_index
|
|
561
|
+
)
|
|
562
|
+
end
|
|
563
|
+
|
|
489
564
|
def focused_pane
|
|
490
565
|
@session.window.focused_pane
|
|
491
566
|
end
|
|
@@ -517,9 +592,11 @@ module Muxr
|
|
|
517
592
|
restore_panes_if_saved(saved) if saved
|
|
518
593
|
|
|
519
594
|
@running = true
|
|
595
|
+
start_foreground_poller
|
|
520
596
|
end
|
|
521
597
|
|
|
522
598
|
def teardown
|
|
599
|
+
stop_foreground_poller
|
|
523
600
|
disconnect_client
|
|
524
601
|
@control_server&.stop
|
|
525
602
|
@control_server = nil
|
|
@@ -796,6 +873,55 @@ module Muxr
|
|
|
796
873
|
# Fire-and-forget pipe to pbcopy. Runs on its own thread so even a slow
|
|
797
874
|
# macOS pbcopy doesn't stall the event loop. Silent when pbcopy is absent
|
|
798
875
|
# (Linux/headless) — selection still goes to the internal buffer.
|
|
876
|
+
# Background thread that walks every pane and writes its foreground
|
|
877
|
+
# command back onto pane.foreground_command. Lives off the event loop
|
|
878
|
+
# because the macOS `ps` path is fork+exec'y; on Linux the procfs reads
|
|
879
|
+
# would be fast enough on the main thread but a single code path is
|
|
880
|
+
# easier to reason about. Atomic pointer writes (MRI GVL) mean we don't
|
|
881
|
+
# need a lock for the renderer's per-frame read.
|
|
882
|
+
def start_foreground_poller
|
|
883
|
+
return if @foreground_poller
|
|
884
|
+
@foreground_poller = Thread.new do
|
|
885
|
+
while @running
|
|
886
|
+
begin
|
|
887
|
+
poll_foreground_commands
|
|
888
|
+
rescue StandardError
|
|
889
|
+
# Never let a poller crash kill the server. If the lookup keeps
|
|
890
|
+
# failing the titles just won't show commands — that's fine.
|
|
891
|
+
end
|
|
892
|
+
sleep FOREGROUND_POLL_INTERVAL
|
|
893
|
+
end
|
|
894
|
+
end
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def stop_foreground_poller
|
|
898
|
+
thread = @foreground_poller
|
|
899
|
+
@foreground_poller = nil
|
|
900
|
+
return unless thread
|
|
901
|
+
# @running has already been flipped off; the thread exits on its next
|
|
902
|
+
# wake. join with a small timeout so we don't hang teardown if the
|
|
903
|
+
# thread is mid-`ps`.
|
|
904
|
+
thread.join(2.0) || thread.kill
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def poll_foreground_commands
|
|
908
|
+
# Snapshot so add/remove on the main thread can't trip us mid-iter.
|
|
909
|
+
panes = @session.window.panes.dup
|
|
910
|
+
drawer_pane = @session.drawer&.pane
|
|
911
|
+
panes << drawer_pane if drawer_pane
|
|
912
|
+
changed = false
|
|
913
|
+
panes.each do |pane|
|
|
914
|
+
next unless pane.alive?
|
|
915
|
+
next unless pane.respond_to?(:pid) && pane.pid
|
|
916
|
+
name = ForegroundCommand.lookup(pane.pid)
|
|
917
|
+
if pane.foreground_command != name
|
|
918
|
+
pane.foreground_command = name
|
|
919
|
+
changed = true
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
invalidate if changed
|
|
923
|
+
end
|
|
924
|
+
|
|
799
925
|
def spawn_pbcopy(text)
|
|
800
926
|
Thread.new do
|
|
801
927
|
IO.popen("pbcopy", "w") { |io| io.write(text) }
|