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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f553caf555f455f8c89689ed8ef73de3158467171551f177a27009363055091
4
- data.tar.gz: b26779047961944af58a74593773b876af42c4e512ed956e0c7cb7a9becead99
3
+ metadata.gz: 8ef82a33d0eab76d850265772196a2d405e7e03b1161496973f4e88369b0e628
4
+ data.tar.gz: 86a621eaef858c02317431a57ac109dea309c3f2f450f0f28b2d30bd71f4d145
5
5
  SHA512:
6
- metadata.gz: 4ab01bd3c63531f1e9b64aecb2532e79eecc4e3736d7970cb1040ee5e9db0fd9f316b06358f1bd758bc93d413ed5e70d17d884ce85ebb4f593cc4d556b180ee3
7
- data.tar.gz: '09abcbbdec950405196dc6015dc00c201c7200c890db0de49ba0c3b703923b78125c0cce458b950c1921970e569e9eb209b1ffd1930ee9cda83ebeb8f88d8afa'
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
- #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
@@ -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) }