muxr 0.1.8 → 0.1.11

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: 16281336febc64c008a43dcd0419fb9ea796dc49f9a36b9ac16f2e20373f302e
4
- data.tar.gz: d28305f5833ecd082ab850e981a074b1c936d0a06829fbfa1be8814d67ed4faa
3
+ metadata.gz: d0bd4e1e9036cc3f25aaa50e92b4183eb123230b3c33877d369d5350b1de8cc4
4
+ data.tar.gz: 55cd2910b19e1f546e6278c7d4532f34f4627be5e30866420e3ebe6b1997f3d1
5
5
  SHA512:
6
- metadata.gz: a31cf649a48541e25b417ab92f9ec14cda5d64acfd0827532792d181effc6b051d5c97234f6fe734ffc97c951819caf857da43a2902ec7068d4f0a5b798c1e40
7
- data.tar.gz: 22b1e5990231d697960c9fe8ef7e7d8c4a6502eb8305497dc5759b8cd5dab785f38f21be92ab835a69afd194b74f1eaf45b184332b64cb5c1d7e97545e937ba1
6
+ metadata.gz: 4782c572a8e4ab2ba193bfd38edb0f11c37e8033decc79b5ebda1b2e2629c56adc8363e9ac000c7314eec508faf98f8b6c40fa9dfa03570ed42e9ccee0c2cfca
7
+ data.tar.gz: 9f39b068f63bfcae8f72dd7caba1a539723d98534d34cac0b15048f902df3440f3e4c51ac18fa6cf8154fc4eecfec8bc7eacc2c692cbe7ca03774db6cb2bc8bb
data/CHANGELOG.md CHANGED
@@ -6,6 +6,82 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.11] - 2026-06-11
10
+
11
+ ### Added
12
+ - Wide (CJK/emoji) and combining character support in the emulator.
13
+ `Terminal.char_width` classifies codepoints as 0/1/2 columns; a width-2
14
+ glyph occupies a lead cell plus an empty continuation cell, and
15
+ zero-width marks fold onto the preceding cell. Search highlights and
16
+ synthetic URL hyperlinks stay aligned on rows containing wide or
17
+ combining glyphs.
18
+ - `r` / `Ctrl-a r` refresh keybinding: a SIGWINCH winsize-wiggle nudges
19
+ the focused program to repaint itself, and a forced full re-emit
20
+ repaints the outer terminal — recovering from display drift whichever
21
+ layer is at fault.
22
+ - Scrollback is now pane-bound: `Ctrl-a` works from inside scrollback
23
+ and selection so pane-switch bindings work without leaving the mode,
24
+ focus returning to a scrolled-back pane resumes where you were
25
+ reading, yank returns to scrollback instead of snapping to the live
26
+ bottom, and `i` drops into insert without losing your place.
27
+ - Opt-in `MUXR_TRACE_OUTPUT` tap: when the env var names a writable
28
+ path, the server appends every byte it sends to the client, so a
29
+ rendering bug can be reproduced from the byte stream alone.
30
+
31
+ ### Fixed
32
+ - The diff renderer now forces an absolute cursor move after
33
+ width-ambiguous glyphs (East Asian Ambiguous symbols, CJK, emoji)
34
+ instead of trusting cursor contiguity, so a width disagreement with
35
+ the outer terminal clips a single glyph rather than shifting the
36
+ entire rest of the line — the "text doesn't line up until I resize"
37
+ bug. Verified against pyte as a reference emulator.
38
+ - Bracketed-paste markers (`\e[200~`/`\e[201~`) are stripped before
39
+ writing to panes whose program never enabled the mode, so pastes no
40
+ longer show literal `^[[200~` text; programs that did enable it still
41
+ receive the markers, and markers split across read boundaries are
42
+ recombined.
43
+
44
+ ### Changed
45
+ - `spiral` is the default layout for new windows (was `tall`). Saved
46
+ sessions are unaffected.
47
+ - New panes and the drawer start in the session origin cwd — the
48
+ directory `bin/muxr` was launched from — instead of the focused
49
+ pane's live cwd. Explicit cwds (MCP `panes.create`, restored
50
+ sessions) still win, and pane creation no longer pays the synchronous
51
+ ~100–300ms `lsof` call on macOS.
52
+
53
+ ## [0.1.10] - 2026-05-29
54
+
55
+ ### Added
56
+ - Six new layouts join `tall`, `grid`, and `monocle`:
57
+ - **`wide`** (`w`) — master on top, slaves split across the bottom.
58
+ - **`columns`** (`|`) — equal-width, full-height vertical strips.
59
+ - **`rows`** (`-`) — equal-height, full-width horizontal strips.
60
+ - **`spiral`** (`f`) — Fibonacci spiral winding inward, each pane half
61
+ the size of the last.
62
+ - **`centered`** (`e`) — master in a centred column with slaves dealt
63
+ to both sides.
64
+ - **`stack`** (`S`) — accordion: the focused pane expands while the
65
+ others collapse to title slivers.
66
+ The `:layout` command resolves any unambiguous name prefix across all
67
+ nine layouts; `C-a Tab` / `Tab` cycles through them in order. README
68
+ screenshots cover every layout.
69
+
70
+ ## [0.1.9] - 2026-05-29
71
+
72
+ ### Added
73
+ - `:layout` accepts short-form prefixes — `:layout t` / `g` / `m` map to
74
+ tall / grid / monocle via prefix matching. Full names still work; an
75
+ ambiguous prefix flashes the candidate layouts.
76
+ - README screenshots are now generated by [VHS](https://github.com/charmbracelet/vhs)
77
+ tapes under `docs/screenshots/tapes/`, regenerated with a single
78
+ `regenerate.sh` run. New captures cover scrollback `/` search and
79
+ movable-cursor visual selection.
80
+
81
+ ### Documentation
82
+ - Documented that wrapped plain-text URLs are stamped with OSC 8
83
+ hyperlink ids (the 0.1.8 feature) in the README architecture section.
84
+
9
85
  ## [0.1.8] - 2026-05-22
10
86
 
11
87
  ### Added
@@ -256,7 +332,13 @@ Initial release.
256
332
  boundaries.
257
333
  - Renderer that composes one frame and diff-emits ANSI to STDOUT.
258
334
 
259
- [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.5...HEAD
335
+ [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.11...HEAD
336
+ [0.1.11]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.11
337
+ [0.1.10]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.10
338
+ [0.1.9]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.9
339
+ [0.1.8]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.8
340
+ [0.1.7]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.7
341
+ [0.1.6]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.6
260
342
  [0.1.5]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.5
261
343
  [0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
262
344
  [0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
data/README.md CHANGED
@@ -33,17 +33,49 @@ with the border color — tracks the current [input mode](#modes).
33
33
 
34
34
  ## Screenshots
35
35
 
36
- The three built-in layouts (pick directly with `t`/`g`/`m` in normal mode, or cycle with `Tab` / `C-a Tab`):
36
+ The built-in layouts (pick directly with the keys below in normal mode, or cycle with `Tab` / `C-a Tab`):
37
+
38
+ | Layout | Key | Geometry |
39
+ |--------|-----|----------|
40
+ | `tall` | `t` | master on the left, slaves stacked on the right |
41
+ | `wide` | `w` | master on top, slaves split across the bottom |
42
+ | `columns` | `\|` | equal-width full-height vertical strips |
43
+ | `rows` | `-` | equal-height full-width horizontal strips |
44
+ | `grid` | `g` | roughly-square even tiling |
45
+ | `spiral` | `f` | Fibonacci spiral winding inward (each pane half the last) |
46
+ | `centered` | `e` | master in a centred column, slaves dealt to both sides |
47
+ | `stack` | `S` | accordion — focused pane expands, others collapse to title slivers |
48
+ | `monocle` | `m` | focused pane fullscreen |
37
49
 
38
50
  <table>
39
51
  <tr>
40
52
  <td align="center"><strong>tall</strong><br/>master + stacked slaves</td>
41
- <td align="center"><strong>grid</strong><br/>even tiling</td>
42
- <td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
53
+ <td align="center"><strong>wide</strong><br/>master on top, slaves below</td>
54
+ <td align="center"><strong>columns</strong><br/>equal-width strips</td>
43
55
  </tr>
44
56
  <tr>
45
57
  <td><img src="docs/screenshots/01-layout-tall.png" alt="tall layout"></td>
58
+ <td><img src="docs/screenshots/05-layout-wide.png" alt="wide layout"></td>
59
+ <td><img src="docs/screenshots/06-layout-columns.png" alt="columns layout"></td>
60
+ </tr>
61
+ <tr>
62
+ <td align="center"><strong>rows</strong><br/>equal-height strips</td>
63
+ <td align="center"><strong>grid</strong><br/>even tiling</td>
64
+ <td align="center"><strong>spiral</strong><br/>Fibonacci spiral</td>
65
+ </tr>
66
+ <tr>
67
+ <td><img src="docs/screenshots/07-layout-rows.png" alt="rows layout"></td>
46
68
  <td><img src="docs/screenshots/02-layout-grid.png" alt="grid layout"></td>
69
+ <td><img src="docs/screenshots/08-layout-spiral.png" alt="spiral layout"></td>
70
+ </tr>
71
+ <tr>
72
+ <td align="center"><strong>centered</strong><br/>master flanked by slaves</td>
73
+ <td align="center"><strong>stack</strong><br/>accordion of title slivers</td>
74
+ <td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
75
+ </tr>
76
+ <tr>
77
+ <td><img src="docs/screenshots/09-layout-centered.png" alt="centered layout"></td>
78
+ <td><img src="docs/screenshots/10-layout-stack.png" alt="stack layout"></td>
47
79
  <td><img src="docs/screenshots/03-layout-monocle.png" alt="monocle layout"></td>
48
80
  </tr>
49
81
  </table>
@@ -52,6 +84,17 @@ The Quake-style drawer overlay (`~` in normal mode, `C-a ~` in passthrough):
52
84
 
53
85
  ![drawer overlay](docs/screenshots/04-drawer.png)
54
86
 
87
+ Scrollback / copy-mode (`s`) with `/` search — matches highlight in
88
+ yellow and the focused pane border turns orange:
89
+
90
+ ![scrollback search](docs/screenshots/05-scrollback-search.png)
91
+
92
+ Movable-cursor visual selection (`v` inside scrollback) — the border
93
+ turns magenta and the swept region is highlighted, ready to yank with
94
+ `y`:
95
+
96
+ ![visual selection](docs/screenshots/06-selection.png)
97
+
55
98
  ## Install / run
56
99
 
57
100
  ```bash
@@ -89,7 +132,7 @@ muxr has two top-level input modes, modeled on vim:
89
132
 
90
133
  - **Normal** (default at startup) — single keys act on the multiplexer.
91
134
  `hjkl` moves focus between panes, `HJKL` moves the focused pane
92
- itself, `c`/`x` create/close panes, `t`/`g`/`m` set the layout, etc.
135
+ itself, `c`/`x` create/close panes, `t`/`w`/`g`/`m` (and `|`/`-`/`f`/`e`/`S`) set the layout, etc.
93
136
  No prefix needed.
94
137
  - **Passthrough** (entered with `i`) — every keystroke is forwarded to
95
138
  the focused pane, exactly like a regular terminal. muxr commands are
@@ -113,7 +156,8 @@ regardless of mode.
113
156
  | `H` / `J` / `K` / `L`| move focused pane left / down / up / right |
114
157
  | `i` | drop into passthrough mode |
115
158
  | `c` / `x` | new / close focused pane (close asks `y/n`) |
116
- | `t` / `g` / `m` | layout: tall / grid / monocle |
159
+ | `t` / `w` / `g` / `m`| layout: tall / wide / grid / monocle |
160
+ | `\|` / `-` / `f` / `e` / `S` | layout: columns / rows / spiral / centered / stack |
117
161
  | `Tab` / `Enter` | cycle layout / promote focused to master |
118
162
  | `a` / `1` … `9` | toggle last pane / jump to pane by number |
119
163
  | `s` | enter scrollback / copy-mode |
@@ -144,7 +188,7 @@ the move falls back to linear next/prev shuffling.
144
188
  | `C-a a` | toggle last (previously focused) pane |
145
189
  | `C-a 1` … `9` | jump to pane by its label |
146
190
  | `C-a x` | close focused pane (asks `y/n`; hides drawer with no prompt) |
147
- | `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`) |
191
+ | `C-a Tab` | cycle layout (`tall` → `wide` → `columns` → `rows` → `grid` → `spiral` → `centered` → `stack` → `monocle`) |
148
192
  | `C-a Enter` | promote focused pane to master |
149
193
  | `C-a ~` | toggle drawer (shell) |
150
194
  | `C-a C` | toggle Claude Code drawer (MCP-aware) |
@@ -206,7 +250,10 @@ focused pane.
206
250
  ## Commands (typed after `:` in normal mode, or `C-a :` in passthrough)
207
251
 
208
252
  ```
209
- layout {tall|grid|monocle} # also: layout (no arg) → cycle
253
+ layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle}
254
+ # any unambiguous name prefix works (t, w, r, g, m, …);
255
+ # ambiguous ones (c → columns/centered, s → spiral/stack)
256
+ # flash the candidates. layout (no arg) → cycle
210
257
  drawer {toggle|show|hide|reset}
211
258
  claude # toggle the Claude Code drawer
212
259
  private # toggle private flag on focused pane
@@ -324,7 +371,11 @@ The per-pane `Terminal` is a real VT100 emulator (cursor movement, SGR
324
371
  including 256-color/truecolor and underline subparameters, erase/insert/
325
372
  delete, autowrap, scroll regions). Scrollback is composited into the
326
373
  visible grid through a view-offset that auto-tracks new rows while
327
- scrolled back, so reviewed content stays frozen.
374
+ scrolled back, so reviewed content stays frozen. Plain-text `http`/
375
+ `https`/`ftp` URLs that wrap across rows are re-stamped with matching
376
+ OSC 8 hyperlink ids after each feed, so terminals like Ghostty, iTerm2,
377
+ kitty, and WezTerm merge the wrapped halves back into one clickable
378
+ link (program-emitted OSC 8 payloads are left untouched).
328
379
 
329
380
  ## Session persistence
330
381
 
@@ -390,6 +441,22 @@ On-disk layout:
390
441
  └─ logs/<name>.log server stdout/stderr
391
442
  ```
392
443
 
444
+ ### Regenerating the README screenshots
445
+
446
+ The PNGs under `docs/screenshots/` are produced by [`vhs`](https://github.com/charmbracelet/vhs)
447
+ driving muxr itself — one `.tape` file per screenshot under
448
+ `docs/screenshots/tapes/`. After a UI change, refresh them with:
449
+
450
+ ```bash
451
+ brew install vhs # one-time
452
+ docs/screenshots/tapes/regenerate.sh # renders all six
453
+ ```
454
+
455
+ Each tape spawns a throwaway `shot` session, populates one or more panes
456
+ with `ls`/`git log`/`wc` output, drives the feature being shown (layout,
457
+ drawer, scrollback search, selection), and writes a single PNG via
458
+ `Screenshot`. Tweak the tape if the keybindings or status bar change.
459
+
393
460
  ## Contributing
394
461
 
395
462
  Contributions are welcome from anyone, with one requirement: **the code
@@ -67,12 +67,36 @@ module Muxr
67
67
  @current_client = nil
68
68
  @client_write_buffer = +"".b
69
69
  @listening_socket = nil
70
+ # The directory bin/muxr was launched from — Process.daemon(true, ...)
71
+ # preserves it across daemonization. Every new pane (and the drawer)
72
+ # starts here, treating it as the session's project root regardless of
73
+ # where the focused pane's shell has wandered.
74
+ @origin_cwd = Dir.pwd
70
75
  @socket_path = self.class.socket_path_for(@session_name)
71
76
  @control_socket_path = self.class.control_socket_path_for(@session_name)
72
77
  @control_server = nil
73
78
  @paste_buffer = +""
79
+ # Trailing bytes of an in-flight INPUT chunk that look like the start of
80
+ # a bracketed-paste marker but were cut off by the 4 KiB read boundary.
81
+ # Held back and prepended to the next chunk so a split marker still gets
82
+ # recognized — see #strip_bracketed_paste_markers.
83
+ @paste_marker_tail = +"".b
74
84
  @last_render_at = nil
75
85
  @foreground_poller = nil
86
+ # Opt-in diagnostic tap. When MUXR_TRACE_OUTPUT names a writable path, the
87
+ # server appends every byte it sends to the client — i.e. exactly what the
88
+ # outer terminal receives. Replaying it (`cat` it into a fresh terminal, or
89
+ # feed it to a reference emulator) reproduces a rendering bug from the byte
90
+ # stream alone, which tells us whether corruption is in muxr's emitted
91
+ # output or somewhere downstream. Off unless the env var is set.
92
+ @trace_output = open_trace(ENV["MUXR_TRACE_OUTPUT"])
93
+ end
94
+
95
+ def open_trace(path)
96
+ return nil if path.nil? || path.empty?
97
+ File.open(path, "ab")
98
+ rescue SystemCallError
99
+ nil
76
100
  end
77
101
 
78
102
  # Interval for the background thread that refreshes each pane's
@@ -94,13 +118,65 @@ module Muxr
94
118
 
95
119
  # ---------- public action API (called from InputHandler / CommandDispatcher) ----------
96
120
 
121
+ # Bytes the outer terminal wraps around a paste once bracketed-paste mode
122
+ # is on (the client enables it unconditionally — see
123
+ # Client#enter_terminal_mode).
124
+ BRACKETED_PASTE_MARKERS = ["\e[200~".b, "\e[201~".b].freeze
125
+
97
126
  def send_to_focused(data)
98
127
  target = focused_target
99
- target&.write(data)
128
+ return unless target
129
+ data = strip_bracketed_paste_markers(data, target)
130
+ target.write(data) unless data.empty?
131
+ end
132
+
133
+ # The client turns bracketed-paste mode on for the *outer* terminal so big
134
+ # pastes arrive wrapped in \e[200~…\e[201~ (which lets shells/editors that
135
+ # speak the protocol collapse them). But the focused program may not speak
136
+ # it — in that case the markers would print as a literal "^[[200~" before
137
+ # and after the text. So: forward the markers untouched when the focused
138
+ # program enabled DECSET 2004, strip them otherwise.
139
+ #
140
+ # A marker can straddle a 4 KiB read boundary, so any trailing bytes that
141
+ # form a partial marker (but not a bare ESC, which must reach the program
142
+ # immediately as the Escape key) are held back and prepended next chunk.
143
+ def strip_bracketed_paste_markers(data, target)
144
+ data = data.b
145
+ term = target.respond_to?(:terminal) ? target.terminal : nil
146
+ buf = @paste_marker_tail + data
147
+ @paste_marker_tail = +"".b
148
+
149
+ if term&.bracketed_paste?
150
+ # Program wants the markers — hand back everything, partial included.
151
+ return buf
152
+ end
153
+
154
+ hold = pending_marker_prefix(buf)
155
+ if hold.positive?
156
+ @paste_marker_tail = buf.byteslice(buf.bytesize - hold, hold)
157
+ buf = buf.byteslice(0, buf.bytesize - hold) || +"".b
158
+ end
159
+ BRACKETED_PASTE_MARKERS.each { |m| buf = buf.gsub(m, "") }
160
+ buf
161
+ end
162
+
163
+ # Length (2..5) of the longest suffix of `buf` that is a proper prefix of a
164
+ # bracketed-paste marker, so the remainder can arrive in the next chunk. A
165
+ # bare trailing ESC (length 1) is deliberately not held: it's almost always
166
+ # the Escape key and the program must see it without waiting on the next
167
+ # keystroke. Worst case a marker split right after its ESC leaks a few
168
+ # bytes, which the program reads as a harmless unknown escape.
169
+ def pending_marker_prefix(buf)
170
+ max = [buf.bytesize, 5].min
171
+ max.downto(2) do |k|
172
+ tail = buf.byteslice(buf.bytesize - k, k)
173
+ return k if BRACKETED_PASTE_MARKERS.any? { |m| m.byteslice(0, k) == tail }
174
+ end
175
+ 0
100
176
  end
101
177
 
102
178
  def new_pane(cwd: nil)
103
- cwd ||= focused_pane&.cwd
179
+ cwd ||= @origin_cwd
104
180
  pane = make_pane(cwd: cwd)
105
181
  @session.window.add_pane(pane)
106
182
  @session.focus_drawer = false
@@ -116,6 +192,7 @@ module Muxr
116
192
  else
117
193
  @session.window.focus_next
118
194
  end
195
+ sync_input_mode_to_focus
119
196
  invalidate
120
197
  end
121
198
 
@@ -126,6 +203,7 @@ module Muxr
126
203
  else
127
204
  @session.window.focus_prev
128
205
  end
206
+ sync_input_mode_to_focus
129
207
  invalidate
130
208
  end
131
209
 
@@ -136,6 +214,7 @@ module Muxr
136
214
  else
137
215
  @session.window.focus_last
138
216
  end
217
+ sync_input_mode_to_focus
139
218
  invalidate
140
219
  end
141
220
 
@@ -145,9 +224,23 @@ module Muxr
145
224
  return unless idx >= 0 && idx < @session.window.panes.length
146
225
  @session.focus_drawer = false
147
226
  @session.window.focus_index(idx)
227
+ sync_input_mode_to_focus
148
228
  invalidate
149
229
  end
150
230
 
231
+ # After a focus change, reconcile the input mode with the newly-focused
232
+ # pane: if it was left scrolled back, re-enter scrollback so the user
233
+ # lands exactly where they were reading ("navigating back to the scrolled
234
+ # pane puts you back into scrollback"). We only ever auto-ENTER here —
235
+ # the InputHandler's @prefix_return is what keeps you in scrollback when
236
+ # you hop onto a live pane, so we never auto-leave.
237
+ def sync_input_mode_to_focus
238
+ target = focused_target
239
+ return unless target&.terminal&.scrolled_back?
240
+ @input.enter_scrollback_mode
241
+ @renderer.reset_frame!
242
+ end
243
+
151
244
  # Move focus to the pane spatially adjacent in `direction` (:left/:right/
152
245
  # :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
153
246
  # layout rects keeps this in sync with whatever the renderer is showing.
@@ -168,12 +261,14 @@ module Muxr
168
261
  when :right, :down then win.focus_next
169
262
  when :left, :up then win.focus_prev
170
263
  end
264
+ sync_input_mode_to_focus
171
265
  invalidate
172
266
  return
173
267
  end
174
268
 
175
269
  return unless idx
176
270
  win.focus_index(idx)
271
+ sync_input_mode_to_focus
177
272
  invalidate
178
273
  end
179
274
 
@@ -275,6 +370,23 @@ module Muxr
275
370
  invalidate
276
371
  end
277
372
 
373
+ # Bound to `r` (normal) / `Ctrl-a r` (passthrough). Two-layer repaint to
374
+ # recover from a corrupted display, whichever layer drifted:
375
+ # 1. Nudge the focused program to redraw itself (SIGWINCH wiggle). This
376
+ # fixes muxr's own Terminal grid when an unhandled or wide glyph
377
+ # desynced the cursor — reset_frame! alone can't, since it would just
378
+ # faithfully re-emit the wrong grid.
379
+ # 2. Force a full re-emit of our composed frame to the outer terminal,
380
+ # fixing the case where the outer display lost/garbled bytes but our
381
+ # grid is correct.
382
+ def refresh_focused
383
+ target = focused_target
384
+ target.request_redraw if target.respond_to?(:request_redraw)
385
+ @renderer.reset_frame!
386
+ flash("refreshed")
387
+ invalidate
388
+ end
389
+
278
390
  def promote_master
279
391
  @session.window.promote_to_master
280
392
  invalidate
@@ -502,7 +614,6 @@ module Muxr
502
614
  def exit_selection(yank:)
503
615
  target = focused_target
504
616
  term = target&.terminal
505
- yanked = false
506
617
  if yank
507
618
  # No anchor → no-op. User is still positioning; they can press v
508
619
  # first, then yank. Esc/q is the way to exit from navigation.
@@ -512,18 +623,14 @@ module Muxr
512
623
  @paste_buffer = text
513
624
  spawn_pbcopy(text)
514
625
  flash("yanked #{text.bytesize} bytes")
515
- yanked = true
516
626
  end
517
627
  end
518
628
  term&.clear_selection
519
- if yanked
520
- # vim-style: yanking drops you straight back to "normal" (idle),
521
- # not back into scrollback navigation.
522
- term&.scroll_to_bottom
523
- @input.enter_idle_mode
524
- else
525
- @input.enter_scrollback_mode
526
- end
629
+ # Drop back into scrollback at the current position whether or not we
630
+ # yanked. We no longer snap to the live bottom on yank — the user stays
631
+ # where they were reading so they can keep selecting or scrolling, and
632
+ # `q`/Esc is still there when they want to return to the bottom.
633
+ @input.enter_scrollback_mode
527
634
  @renderer.reset_frame!
528
635
  invalidate
529
636
  end
@@ -610,6 +717,9 @@ module Muxr
610
717
  # the server is also trying to read from that same client.
611
718
  def deliver_output(bytes)
612
719
  return unless @current_client
720
+ if @trace_output
721
+ @trace_output.write(bytes) rescue nil
722
+ end
613
723
  @client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
614
724
  drain_client_writes
615
725
  end
@@ -718,6 +828,10 @@ module Muxr
718
828
  end
719
829
  @session&.window&.panes&.each(&:close)
720
830
  @session&.drawer&.close
831
+ if @trace_output
832
+ @trace_output.close rescue nil
833
+ @trace_output = nil
834
+ end
721
835
  end
722
836
 
723
837
  def loop_forever
@@ -1049,7 +1163,7 @@ module Muxr
1049
1163
 
1050
1164
  def ensure_drawer(command: nil)
1051
1165
  return if @session.drawer
1052
- cwd = focused_pane&.cwd
1166
+ cwd = @origin_cwd
1053
1167
  pane = Pane.new(
1054
1168
  id: :drawer,
1055
1169
  rows: 10,
@@ -45,10 +45,12 @@ module Muxr
45
45
  @app.cycle_layout
46
46
  return
47
47
  end
48
- sym = name.to_sym
49
- if Window::LAYOUTS.include?(sym)
50
- @app.session.window.set_layout(sym)
48
+ matches = Window::LAYOUTS.select { |l| l.to_s.start_with?(name) }
49
+ if matches.length == 1
50
+ @app.session.window.set_layout(matches.first)
51
51
  @app.invalidate
52
+ elsif matches.length > 1
53
+ @app.flash("ambiguous layout: #{name} (#{matches.join(", ")})")
52
54
  else
53
55
  @app.flash("unknown layout: #{name}")
54
56
  end
@@ -19,6 +19,15 @@ module Muxr
19
19
  # finish. :scrollback and :selection also return to @base_mode so that
20
20
  # exiting back from a scroll/yank lands you back in passthrough if that's
21
21
  # where you came from.
22
+ #
23
+ # Scrollback is effectively pane-bound. Ctrl-a is honored from inside
24
+ # :scrollback and :selection — it drops into :prefix (with @prefix_return
25
+ # = :scrollback) so a pane switch keeps you in scrollback on the pane you
26
+ # move to, while the pane you left keeps its own scroll position. Coming
27
+ # the other way, the Application re-enters scrollback whenever you focus a
28
+ # pane that was left scrolled back. `i` from scrollback drops to insert
29
+ # (passthrough) without snapping to the bottom; only `q`/Esc returns the
30
+ # pane to the live bottom.
22
31
  class InputHandler
23
32
  PREFIX = "\x01".freeze # Ctrl-a
24
33
 
@@ -31,8 +40,14 @@ module Muxr
31
40
  "c" => :new_pane,
32
41
  "x" => :request_close,
33
42
  "t" => [:set_layout, :tall],
43
+ "w" => [:set_layout, :wide],
34
44
  "g" => [:set_layout, :grid],
35
45
  "m" => [:set_layout, :monocle],
46
+ "|" => [:set_layout, :columns],
47
+ "-" => [:set_layout, :rows],
48
+ "f" => [:set_layout, :spiral],
49
+ "e" => [:set_layout, :centered],
50
+ "S" => [:set_layout, :stack],
36
51
  "\t" => :cycle_layout,
37
52
  "\r" => :promote_master,
38
53
  "\n" => :promote_master,
@@ -45,6 +60,7 @@ module Muxr
45
60
  "K" => [:move_direction, :up],
46
61
  "L" => [:move_direction, :right],
47
62
  "a" => :focus_last,
63
+ "r" => :refresh_focused,
48
64
  "~" => :toggle_drawer,
49
65
  "C" => :toggle_claude_drawer,
50
66
  "P" => :toggle_private_focused,
@@ -60,6 +76,7 @@ module Muxr
60
76
  "n" => :focus_next,
61
77
  "p" => :focus_prev,
62
78
  "a" => :focus_last,
79
+ "r" => :refresh_focused,
63
80
  "x" => :request_close,
64
81
  "\t" => :cycle_layout,
65
82
  "\r" => :promote_master,
@@ -152,6 +169,11 @@ module Muxr
152
169
  @command_buffer = +""
153
170
  @search_buffer = +""
154
171
  @search_direction = :forward
172
+ # When the prefix state is entered from scrollback/selection (Ctrl-a),
173
+ # this records :scrollback so that a pane switch lands you back in
174
+ # scrollback on the newly-focused pane instead of dropping to the base
175
+ # mode. nil means "use @base_mode" (the normal passthrough behavior).
176
+ @prefix_return = nil
155
177
  end
156
178
 
157
179
  def feed(data)
@@ -299,6 +321,12 @@ module Muxr
299
321
  end
300
322
 
301
323
  def handle_prefix(ch)
324
+ # Where to land once the prefix binding finishes. Normally the base
325
+ # mode, but :scrollback when we entered the prefix from scrollback /
326
+ # selection so a pane switch keeps you in scrollback on the new pane.
327
+ # Consume it immediately so it never leaks into the next prefix.
328
+ ret = @prefix_return || @base_mode
329
+ @prefix_return = nil
302
330
  action = PREFIX_BINDINGS[ch]
303
331
  case
304
332
  when ch == "\e"
@@ -315,15 +343,18 @@ module Muxr
315
343
  @state = @base_mode
316
344
  when DIGIT_RE.match?(ch)
317
345
  @app.focus_pane_number(ch.to_i)
318
- @state = @base_mode
346
+ # The focus action may auto-enter scrollback (landing on a pane that
347
+ # was left scrolled). Only fall back to `ret` if it didn't.
348
+ @state = ret if @state == :prefix
319
349
  when action
320
350
  @app.public_send(action)
321
351
  # The action may have set a new state (confirm_quit, confirm_close,
322
- # scrollback, help). Only revert to base mode if we're still in :prefix.
323
- @state = @base_mode if @state == :prefix
352
+ # scrollback via auto-enter, help). Only fall back to `ret` if we're
353
+ # still in :prefix.
354
+ @state = ret if @state == :prefix
324
355
  else
325
- # Unknown prefix key: return to base mode silently.
326
- @state = @base_mode
356
+ # Unknown prefix key: return to where we came from silently.
357
+ @state = ret
327
358
  end
328
359
  end
329
360
 
@@ -346,6 +377,25 @@ module Muxr
346
377
  end
347
378
 
348
379
  def handle_scrollback_input(ch)
380
+ if ch == PREFIX
381
+ # Ctrl-a is the escape hatch even from scrollback: drop into the
382
+ # prefix state so the user can switch panes (Ctrl-a n/p/a/1-9) or
383
+ # run any other prefix binding without first leaving scrollback.
384
+ # @prefix_return = :scrollback keeps the user in scrollback on the
385
+ # pane they switch to; the source pane keeps its scroll position so
386
+ # it stays put. Scrollback is effectively pane-bound now.
387
+ @prefix_return = :scrollback
388
+ @state = :prefix
389
+ return
390
+ end
391
+ if ch == "i"
392
+ # Drop straight into insert (passthrough) without snapping to the
393
+ # live bottom — the pane stays where it's scrolled. Mirrors the
394
+ # normal-mode `i` so "type now" is one key from scrollback too.
395
+ enter_passthrough_mode
396
+ @app.enter_passthrough_mode
397
+ return
398
+ end
349
399
  if SCROLLBACK_EXITS.include?(ch)
350
400
  enter_idle_mode
351
401
  @app.exit_scrollback
@@ -428,6 +478,16 @@ module Muxr
428
478
  end
429
479
 
430
480
  def handle_selection_input(ch)
481
+ if ch == PREFIX
482
+ # Same escape hatch as scrollback: Ctrl-a enters the prefix state so
483
+ # pane switching (and any other prefix binding) works mid-selection.
484
+ # We return to :scrollback (not :selection) on the new pane — you
485
+ # don't want to be mid-select on a pane you just arrived at — while
486
+ # the source pane keeps its scroll position and selection intact.
487
+ @prefix_return = :scrollback
488
+ @state = :prefix
489
+ return
490
+ end
431
491
  if SELECTION_YANK.include?(ch)
432
492
  @app.exit_selection(yank: true)
433
493
  return
@@ -10,7 +10,7 @@ module Muxr
10
10
  end
11
11
  end
12
12
 
13
- LAYOUTS = %i[tall grid monocle].freeze
13
+ LAYOUTS = %i[tall wide columns rows grid spiral centered stack monocle].freeze
14
14
 
15
15
  module_function
16
16
 
@@ -19,9 +19,15 @@ module Muxr
19
19
  master_index = master_index.clamp(0, count - 1)
20
20
  focused_index = focused_index.clamp(0, count - 1)
21
21
  case layout
22
- when :tall then tall(count, area, master_index)
23
- when :grid then grid(count, area)
24
- when :monocle then monocle(count, area, focused_index)
22
+ when :tall then tall(count, area, master_index)
23
+ when :wide then wide(count, area, master_index)
24
+ when :columns then columns(count, area)
25
+ when :rows then rows(count, area)
26
+ when :grid then grid(count, area)
27
+ when :spiral then spiral(count, area)
28
+ when :centered then centered(count, area, master_index)
29
+ when :stack then stack(count, area, focused_index)
30
+ when :monocle then monocle(count, area, focused_index)
25
31
  else
26
32
  raise ArgumentError, "Unknown layout: #{layout.inspect}"
27
33
  end
@@ -52,6 +58,153 @@ module Muxr
52
58
  rects
53
59
  end
54
60
 
61
+ # The transpose of `tall`: master pane spans the full width across the top
62
+ # half; remaining panes sit side-by-side in the bottom half, dividing the
63
+ # remaining width evenly.
64
+ def wide(count, area, master_index = 0)
65
+ master_index = master_index.clamp(0, count - 1)
66
+ return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
67
+
68
+ master_h = [area.h / 2, 1].max
69
+ stack_h = [area.h - master_h, 1].max
70
+ others = (0...count).to_a - [master_index]
71
+ slave_count = others.length
72
+ base_w = area.w / slave_count
73
+ remainder = area.w - base_w * slave_count
74
+
75
+ rects = Array.new(count)
76
+ rects[master_index] = Rect.new(area.x, area.y, area.w, master_h)
77
+
78
+ x = area.x
79
+ others.each_with_index do |idx, i|
80
+ w = base_w + (i < remainder ? 1 : 0)
81
+ rects[idx] = Rect.new(x, area.y + master_h, w, stack_h)
82
+ x += w
83
+ end
84
+ rects
85
+ end
86
+
87
+ # Equal-width, full-height vertical strips, side by side. No master.
88
+ def columns(count, area)
89
+ base_w = area.w / count
90
+ rem = area.w - base_w * count
91
+ rects = []
92
+ x = area.x
93
+ count.times do |i|
94
+ w = base_w + (i < rem ? 1 : 0)
95
+ rects << Rect.new(x, area.y, w, area.h)
96
+ x += w
97
+ end
98
+ rects
99
+ end
100
+
101
+ # Equal-height, full-width horizontal strips, stacked. The dual of columns.
102
+ def rows(count, area)
103
+ base_h = area.h / count
104
+ rem = area.h - base_h * count
105
+ rects = []
106
+ y = area.y
107
+ count.times do |i|
108
+ h = base_h + (i < rem ? 1 : 0)
109
+ rects << Rect.new(area.x, y, area.w, h)
110
+ y += h
111
+ end
112
+ rects
113
+ end
114
+
115
+ # Fibonacci spiral: each pane takes half of the remaining region, splitting
116
+ # vertically then horizontally in alternation, so panes wind inward toward
117
+ # the bottom-right. The last pane fills whatever is left.
118
+ def spiral(count, area)
119
+ x, y, w, h = area.x, area.y, area.w, area.h
120
+ rects = []
121
+ count.times do |i|
122
+ if i == count - 1
123
+ rects << Rect.new(x, y, w, h)
124
+ elsif i.even?
125
+ left = [w / 2, 1].max
126
+ rects << Rect.new(x, y, left, h)
127
+ x += left
128
+ w = [w - left, 1].max
129
+ else
130
+ top = [h / 2, 1].max
131
+ rects << Rect.new(x, y, w, top)
132
+ y += top
133
+ h = [h - top, 1].max
134
+ end
135
+ end
136
+ rects
137
+ end
138
+
139
+ # Three-column master: master occupies the centre column full-height; the
140
+ # remaining panes are dealt alternately to a left and a right column and
141
+ # stacked within each. With a single slave there is no symmetry to keep, so
142
+ # it falls back to a simple master/slave vertical split (like `tall`).
143
+ def centered(count, area, master_index = 0)
144
+ master_index = master_index.clamp(0, count - 1)
145
+ return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
146
+
147
+ others = (0...count).to_a - [master_index]
148
+ rects = Array.new(count)
149
+
150
+ if others.length == 1
151
+ master_w = [area.w / 2, 1].max
152
+ rects[master_index] = Rect.new(area.x, area.y, master_w, area.h)
153
+ rects[others[0]] = Rect.new(area.x + master_w, area.y, [area.w - master_w, 1].max, area.h)
154
+ return rects
155
+ end
156
+
157
+ master_w = [area.w / 2, 1].max
158
+ side_w = area.w - master_w
159
+ left_w = [side_w / 2, 1].max
160
+ right_w = [side_w - left_w, 1].max
161
+
162
+ rects[master_index] = Rect.new(area.x + left_w, area.y, master_w, area.h)
163
+ left = others.select.with_index { |_, i| i.even? }
164
+ right = others.select.with_index { |_, i| i.odd? }
165
+ stack_column(rects, left, area.x, area.y, left_w, area.h)
166
+ stack_column(rects, right, area.x + left_w + master_w, area.y, right_w, area.h)
167
+ rects
168
+ end
169
+
170
+ # Accordion: the focused pane expands to fill the leftover height while the
171
+ # others collapse to short "title sliver" rows, all stacked vertically.
172
+ # Like monocle but the other panes stay visible (and spatially reachable).
173
+ def stack(count, area, focused_index = 0)
174
+ return [Rect.new(area.x, area.y, area.w, area.h)] if count == 1
175
+ focused_index = focused_index.clamp(0, count - 1)
176
+
177
+ others = count - 1
178
+ # Sliver is 3 rows so draw_box can still render the title; shrink it only
179
+ # when the terminal is too short to give the focused pane its own 3 rows.
180
+ sliver = [3, [area.h - 3, 0].max / others].min
181
+ sliver = [sliver, 1].max
182
+ focus_h = area.h - sliver * others
183
+
184
+ rects = Array.new(count)
185
+ y = area.y
186
+ count.times do |i|
187
+ h = (i == focused_index) ? focus_h : sliver
188
+ rects[i] = Rect.new(area.x, y, area.w, h)
189
+ y += h
190
+ end
191
+ rects
192
+ end
193
+
194
+ # Stack the given pane indices vertically within a single column, dividing
195
+ # the height evenly (remainder to the topmost panes). Used by `centered`.
196
+ def stack_column(rects, indices, x, y, w, total_h)
197
+ return if indices.empty?
198
+ base_h = total_h / indices.length
199
+ rem = total_h - base_h * indices.length
200
+ cy = y
201
+ indices.each_with_index do |idx, i|
202
+ h = base_h + (i < rem ? 1 : 0)
203
+ rects[idx] = Rect.new(x, cy, w, h)
204
+ cy += h
205
+ end
206
+ end
207
+
55
208
  # Roughly square grid. Each row stretches its panes to fill the full width
56
209
  # so an underfull bottom row doesn't leave gaps.
57
210
  def grid(count, area)
data/lib/muxr/pane.rb CHANGED
@@ -117,6 +117,12 @@ module Muxr
117
117
  @process.resize(rows, cols)
118
118
  end
119
119
 
120
+ # Force the inner program to repaint itself (see PTYProcess#nudge_redraw).
121
+ # Used by the refresh keybinding to recover from emulation drift.
122
+ def request_redraw
123
+ @process.nudge_redraw
124
+ end
125
+
120
126
  def alive?
121
127
  @process.alive?
122
128
  end
@@ -81,6 +81,26 @@ module Muxr
81
81
  end
82
82
  end
83
83
 
84
+ # Coax the foreground program into repainting from scratch by briefly
85
+ # toggling the PTY window size, which delivers SIGWINCH to the tty's
86
+ # foreground process group. Full-screen TUIs (vim, htop, less, fzf) redraw
87
+ # on WINCH, which rewrites muxr's Terminal grid and clears any emulation
88
+ # drift (e.g. a wide glyph that desynced the cursor). The size is restored
89
+ # immediately, so the program redraws at the real dimensions: it reads the
90
+ # current (restored) winsize in its handler and never observes the
91
+ # transient size. No-op when the pane is too narrow to wiggle.
92
+ def nudge_redraw
93
+ return if @exited
94
+ smaller = [@cols - 1, 1].max
95
+ return if smaller == @cols
96
+ begin
97
+ @reader.winsize = [@rows, smaller, 0, 0]
98
+ @reader.winsize = [@rows, @cols, 0, 0]
99
+ rescue StandardError
100
+ # Some platforms reject winsize pokes; reset_frame! still re-emits.
101
+ end
102
+ end
103
+
84
104
  def alive?
85
105
  return false if @exited
86
106
  Process.kill(0, @pid)
data/lib/muxr/renderer.rb CHANGED
@@ -391,9 +391,11 @@ module Muxr
391
391
  " H / J / K / L move pane left / down / up / right",
392
392
  " i drop into passthrough mode",
393
393
  " c / x new / close pane (close asks y/n)",
394
- " t / g / m layout: tall / grid / monocle",
394
+ " t / w / g / m layout: tall / wide / grid / monocle",
395
+ " | - f e S layout: columns / rows / spiral / centered / stack",
395
396
  " Tab / Enter cycle layout / promote to master",
396
397
  " a / 1..9 last pane / jump by number",
398
+ " r refresh / redraw (fixes a corrupted pane)",
397
399
  " s enter scrollback",
398
400
  " ~ / C / P drawer / Claude drawer / toggle private",
399
401
  " : / ? command prompt / toggle this help",
@@ -401,19 +403,24 @@ module Muxr
401
403
  "",
402
404
  "PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
403
405
  " C-a Esc return to normal mode",
404
- " C-a c x t g m same as normal-mode bindings",
406
+ " C-a c x t w g m same as normal-mode bindings",
405
407
  " C-a Tab Enter cycle layout / promote master",
406
408
  " C-a n / p / a next / prev / last pane",
409
+ " C-a r refresh / redraw (fixes a corrupted pane)",
407
410
  " C-a [ ] scrollback / paste buffer",
408
411
  " C-a C-a send literal Ctrl-a to focused pane",
409
412
  "",
410
- "SCROLLBACK mode (exits to the mode you came from)",
413
+ "SCROLLBACK mode (pane-bound: follows you as you switch panes)",
411
414
  " j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
412
415
  " / search-fwd ? search-back n/N next/prev match",
416
+ " C-a n/p/a/1-9 switch pane, stay in scrollback (each keeps its pos)",
417
+ " i insert here (keeps scroll pos) q/Esc exit to live bottom",
413
418
  " cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
414
- " v select, C-v block, y/Enter yank, q/Esc cancel",
419
+ " v select, C-v block, y/Enter yank (stays in scrollback)",
420
+ " q/Esc cancel C-a n/p/a/1-9 switch pane",
415
421
  "",
416
- "Commands: layout {tall|grid|monocle}, drawer {toggle|show|hide|reset},",
422
+ "Commands: layout {tall|wide|columns|rows|grid|spiral|centered|stack|monocle},",
423
+ " drawer {toggle|show|hide|reset},",
417
424
  " claude, save, restore, sessions, quit, new, close, next, prev",
418
425
  "",
419
426
  "press any key to dismiss"
@@ -541,6 +548,11 @@ module Muxr
541
548
  if same_size && @prev[y][x] == cell
542
549
  next
543
550
  end
551
+ # The right half of a wide glyph (char "") is painted by its lead
552
+ # cell to the left, which spans both columns in the outer terminal.
553
+ # Emitting anything here would clobber that glyph, so skip it — and
554
+ # leave last_x untouched, since we didn't move the outer cursor.
555
+ next if cell.char.empty?
544
556
  if last_y != y || last_x != x
545
557
  out << "\e[#{y + 1};#{x + 1}H"
546
558
  end
@@ -557,7 +569,19 @@ module Muxr
557
569
  end
558
570
  out << cell.char
559
571
  last_y = y
560
- last_x = x + cell.char.length
572
+ if contiguous_after?(cell.char)
573
+ # Advance by the glyph's display width, not its codepoint count: a
574
+ # wide char ("中") moves the outer cursor two columns though it's
575
+ # one codepoint, and a base+combining cell ("é") moves one though
576
+ # it's two. Keeping last_x in sync with the real cursor lets the
577
+ # next cell skip a redundant CUP.
578
+ last_x = x + Terminal.char_width(cell.char.ord)
579
+ else
580
+ # The outer terminal might advance its cursor by a different number
581
+ # of columns than we think for this glyph — force an absolute
582
+ # reposition for the next cell so the disagreement can't cascade.
583
+ last_x = nil
584
+ end
561
585
  end
562
586
  end
563
587
  out << "\e]8;;\e\\" if cur_hyperlink
@@ -571,6 +595,24 @@ module Muxr
571
595
  @prev_h = frame.length
572
596
  end
573
597
 
598
+ # Whether we can trust the outer terminal's cursor to be exactly one
599
+ # display-width past this glyph, so the next contiguous cell needs no
600
+ # cursor-position escape. Safe only for glyphs whose width every terminal
601
+ # agrees on: ASCII (always one column), and the box-drawing / block-element
602
+ # band 0x2500–0x259F (reliably one column, and we emit a lot of them for
603
+ # borders, so keeping them contiguous matters). Everything else non-ASCII —
604
+ # CJK, emoji, and East Asian Ambiguous symbols like ·, …, ●, arrows, and
605
+ # the ⏺/✻/❯ glyphs Claude Code's UI is full of — can be drawn two columns
606
+ # wide by some terminals. We can't know which, so we force an absolute
607
+ # reposition after them: a width disagreement then clips a single glyph
608
+ # instead of shifting the whole rest of the line. A base+combining cell
609
+ # (multi-codepoint) is treated the same way out of caution.
610
+ def contiguous_after?(char)
611
+ return false if char.length > 1
612
+ cp = char.ord
613
+ cp < 0x80 || (cp >= 0x2500 && cp <= 0x259F)
614
+ end
615
+
574
616
  def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
575
617
  if input_state == :command
576
618
  col = 1 + command_buffer.length + 1 # ':' + buffer
data/lib/muxr/terminal.rb CHANGED
@@ -12,6 +12,67 @@ module Muxr
12
12
 
13
13
  SCROLLBACK_MAX = 5000
14
14
 
15
+ # Codepoint ranges that occupy two display columns (East Asian Wide /
16
+ # Fullwidth per UAX #11, plus the common emoji blocks). A wide glyph is
17
+ # stored in its lead cell with a continuation cell (char "") to its right
18
+ # reserving the second column — see #put_char. Kept as a flat, sorted list
19
+ # of ranges; #char_width only consults it for codepoints >= 0x300, so the
20
+ # ASCII/Latin-1 fast path never pays for the scan.
21
+ WIDE_RANGES = [
22
+ 0x1100..0x115F, # Hangul Jamo
23
+ 0x2329..0x232A, # angle brackets
24
+ 0x2E80..0x303E, # CJK radicals, Kangxi, CJK symbols & punctuation
25
+ 0x3041..0x33FF, # Hiragana … CJK compatibility
26
+ 0x3400..0x4DBF, # CJK Unified Ext A
27
+ 0x4E00..0x9FFF, # CJK Unified Ideographs
28
+ 0xA000..0xA4CF, # Yi
29
+ 0xA960..0xA97F, # Hangul Jamo Ext-A
30
+ 0xAC00..0xD7A3, # Hangul Syllables
31
+ 0xF900..0xFAFF, # CJK Compatibility Ideographs
32
+ 0xFE10..0xFE19, # vertical forms
33
+ 0xFE30..0xFE6F, # CJK compatibility / small form variants
34
+ 0xFF00..0xFF60, # Fullwidth Forms
35
+ 0xFFE0..0xFFE6, # Fullwidth signs
36
+ 0x1B000..0x1B16F, # Kana supplement / extended
37
+ 0x1F300..0x1F64F, # Misc symbols & pictographs, emoticons
38
+ 0x1F680..0x1F6FF, # transport & map symbols
39
+ 0x1F900..0x1F9FF, # supplemental symbols & pictographs
40
+ 0x1FA70..0x1FAFF, # symbols & pictographs extended-A
41
+ 0x20000..0x3FFFD # CJK Unified Ext B and beyond
42
+ ].freeze
43
+
44
+ # Codepoint ranges that occupy zero display columns: combining marks,
45
+ # variation selectors, and zero-width formatting characters. These fold
46
+ # onto the preceding glyph rather than consuming a column (#attach_combining)
47
+ # so the cursor stays aligned with what a real terminal would do.
48
+ ZERO_WIDTH_RANGES = [
49
+ 0x0300..0x036F, # combining diacritical marks
50
+ 0x0483..0x0489, # Cyrillic combining
51
+ 0x0591..0x05BD, 0x05BF..0x05BF, 0x05C1..0x05C2, 0x05C4..0x05C5,
52
+ 0x0610..0x061A, 0x064B..0x065F, 0x0670..0x0670,
53
+ 0x06D6..0x06DC, 0x06DF..0x06E4, 0x06E7..0x06E8, 0x06EA..0x06ED,
54
+ 0x0711..0x0711, 0x0730..0x074A,
55
+ 0x200B..0x200F, # zero-width space/joiner/non-joiner, marks
56
+ 0x2028..0x202E, 0x2060..0x2064,
57
+ 0x20D0..0x20FF, # combining marks for symbols
58
+ 0x1AB0..0x1AFF, 0x1DC0..0x1DFF, # combining extensions
59
+ 0xFE00..0xFE0F, # variation selectors
60
+ 0xFE20..0xFE2F, # combining half marks
61
+ 0xFEFF..0xFEFF, # BOM / zero-width no-break space
62
+ 0xE0100..0xE01EF # variation selectors supplement
63
+ ].freeze
64
+
65
+ # Display width of a codepoint in terminal columns: 0 (combining /
66
+ # zero-width), 2 (East Asian wide / emoji), or 1 (everything else). The
67
+ # Renderer uses this to advance its emit cursor by the right number of
68
+ # columns; #put_char uses it to lay glyphs into the grid.
69
+ def self.char_width(cp)
70
+ return 1 if cp < 0x0300
71
+ return 0 if ZERO_WIDTH_RANGES.any? { |r| r.cover?(cp) }
72
+ return 2 if WIDE_RANGES.any? { |r| r.cover?(cp) }
73
+ 1
74
+ end
75
+
15
76
  # Cap on the OSC payload we buffer before parsing. URLs in OSC 8 can be
16
77
  # long but rarely exceed a few hundred bytes; 4 KiB lets the parser stay
17
78
  # tolerant of weird inputs without giving an attacker an unbounded sink.
@@ -96,6 +157,11 @@ module Muxr
96
157
  @selection_mode = :linear
97
158
  @sync_pending = false
98
159
  @sync_started_at = nil
160
+ # True once the inner program enables bracketed-paste mode (DECSET
161
+ # 2004). The Application consults this to decide whether to forward the
162
+ # \e[200~…\e[201~ paste markers the outer terminal wraps around a paste
163
+ # or strip them — see Application#send_to_focused.
164
+ @bracketed_paste = false
99
165
  @pending_replies = +"".b
100
166
  @search_query = nil
101
167
  @search_direction = :forward
@@ -134,6 +200,14 @@ module Muxr
134
200
  @sync_started_at + SYNC_TIMEOUT
135
201
  end
136
202
 
203
+ # True iff the inner program has enabled bracketed-paste mode (DECSET
204
+ # 2004). When false, the Application strips paste markers before writing
205
+ # so a program that doesn't speak bracketed paste never prints a literal
206
+ # "^[[200~".
207
+ def bracketed_paste?
208
+ @bracketed_paste
209
+ end
210
+
137
211
  attr_reader :selection_mode
138
212
 
139
213
  def cell(r, c)
@@ -578,12 +652,20 @@ module Muxr
578
652
  end
579
653
  end
580
654
 
655
+ # Build the scan text alongside a codepoint→cell map. A wide
656
+ # continuation half (char "") contributes no codepoints, and a
657
+ # base+combining cell contributes more than one, so we can't assume the
658
+ # old 1:1 cell↔codepoint indexing — map every codepoint back to its
659
+ # source cell instead. URLs are ASCII, but a wide glyph earlier on the
660
+ # line would otherwise shift every later offset off its cell.
581
661
  text = String.new(capacity: rows.length * @cols)
582
662
  cells = []
583
663
  rows.each do |row|
584
664
  row.each do |cell|
585
- text << cell.char
586
- cells << cell
665
+ ch = cell.char
666
+ next if ch.empty?
667
+ ch.each_char { cells << cell }
668
+ text << ch
587
669
  end
588
670
  end
589
671
 
@@ -773,19 +855,20 @@ module Muxr
773
855
  when ">", "<", "=", "!"
774
856
  return
775
857
  when "?"
776
- # DEC private modes — most we treat as no-ops, but mode 2026
777
- # (Synchronized Output) is a render-timing hint we honor so the
778
- # outer paint lands on fully-formed frames from fzf/nvim/helix.
858
+ # DEC private modes — most we treat as no-ops, but two we track:
859
+ # 2026 (Synchronized Output) a render-timing hint we honor so the
860
+ # outer paint lands on fully-formed frames from fzf/nvim/helix.
861
+ # 2004 (Bracketed Paste) — whether the inner program wants pastes
862
+ # wrapped in \e[200~…\e[201~; the Application strips those
863
+ # markers when it's off (see Application#send_to_focused).
779
864
  if final == "h" || final == "l"
780
- if csi_params.include?(2026)
781
- if final == "h"
782
- @sync_pending = true
783
- @sync_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
784
- else
785
- @sync_pending = false
786
- @sync_started_at = nil
787
- end
865
+ enabled = (final == "h")
866
+ params = csi_params
867
+ if params.include?(2026)
868
+ @sync_pending = enabled
869
+ @sync_started_at = enabled ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
788
870
  end
871
+ @bracketed_paste = enabled if params.include?(2004)
789
872
  end
790
873
  return
791
874
  end
@@ -879,22 +962,65 @@ module Muxr
879
962
  end
880
963
 
881
964
  def put_char(ch)
965
+ width = self.class.char_width(ch.ord)
966
+
967
+ # Zero-width: fold the mark onto the preceding glyph instead of taking a
968
+ # column, so the cursor stays where a real terminal would leave it.
969
+ return attach_combining(ch) if width.zero?
970
+
882
971
  if @autowrap_pending
883
972
  @cursor_col = 0
884
973
  line_feed
885
974
  @autowrap_pending = false
886
975
  end
887
- cell = @buffer[@cursor_row][@cursor_col]
976
+
977
+ # A wide glyph needs two columns; if only the last column is free, leave
978
+ # it blank and wrap first (matching xterm/VTE deferral behavior).
979
+ if width == 2 && @cursor_col == @cols - 1
980
+ @buffer[@cursor_row][@cursor_col].reset!
981
+ @cursor_col = 0
982
+ line_feed
983
+ end
984
+
985
+ c = @cursor_col
986
+ write_cell(@buffer[@cursor_row][c], ch)
987
+ if width == 2
988
+ # The continuation half carries no glyph (char "") but inherits the
989
+ # lead's colors so a styled wide cell paints both columns; the Renderer
990
+ # skips emitting it since the lead already covers both columns.
991
+ write_cell(@buffer[@cursor_row][c + 1], "")
992
+ end
993
+
994
+ last_col = c + width - 1
995
+ if last_col >= @cols - 1
996
+ @cursor_col = @cols - 1
997
+ @autowrap_pending = true
998
+ else
999
+ @cursor_col = last_col + 1
1000
+ end
1001
+ end
1002
+
1003
+ def write_cell(cell, ch)
888
1004
  cell.char = ch
889
1005
  cell.fg = @fg
890
1006
  cell.bg = @bg
891
1007
  cell.attrs = @attrs
892
1008
  cell.hyperlink = @current_hyperlink
893
- if @cursor_col >= @cols - 1
894
- @autowrap_pending = true
895
- else
896
- @cursor_col += 1
897
- end
1009
+ end
1010
+
1011
+ # Fold a zero-width mark (combining accent, variation selector, …) onto the
1012
+ # glyph in the cell the cursor just left, so the outer terminal composes
1013
+ # them (e + ◌́ → é) without the mark consuming a column. Marks with nothing
1014
+ # to attach to — start of line, or landing on a wide continuation half —
1015
+ # are dropped; column alignment matters more than the lost accent.
1016
+ def attach_combining(ch)
1017
+ target =
1018
+ if @autowrap_pending then @buffer[@cursor_row][@cols - 1]
1019
+ elsif @cursor_col > 0 then @buffer[@cursor_row][@cursor_col - 1]
1020
+ end
1021
+ return unless target
1022
+ return if target.char.empty?
1023
+ target.char += ch
898
1024
  end
899
1025
 
900
1026
  def line_feed
@@ -1035,12 +1161,25 @@ module Muxr
1035
1161
  timeline_size.times do |tr|
1036
1162
  row = timeline_row(tr)
1037
1163
  next if row.nil?
1164
+ # Build the row text and a parallel codepoint→column map so matches can
1165
+ # be reported in column coordinates even when wide glyphs (one cell, two
1166
+ # columns) and combining marks (multi-codepoint, one cell) break the
1167
+ # 1:1 char-index↔column relationship. For all-ASCII rows col_at[i] == i,
1168
+ # so this is identical to the old behavior on the common path.
1038
1169
  line = String.new(capacity: @cols)
1039
- @cols.times { |c| line << (row[c]&.char || " ") }
1170
+ col_at = []
1171
+ @cols.times do |c|
1172
+ ch = row[c]&.char
1173
+ next if ch == "" # wide continuation half — occupies no text slot
1174
+ ch = " " if ch.nil?
1175
+ ch.each_char { col_at << c }
1176
+ line << ch
1177
+ end
1040
1178
  haystack = case_sensitive ? line : line.downcase
1041
1179
  start = 0
1042
1180
  while (idx = haystack.index(needle, start))
1043
- matches << [tr, idx, idx + needle.length - 1]
1181
+ last = idx + needle.length - 1
1182
+ matches << [tr, col_at[idx], col_at[last] || col_at.last || idx]
1044
1183
  # Advance past the start of this match so overlapping needles
1045
1184
  # ("aa" in "aaaa") still emit one match per starting position.
1046
1185
  start = idx + 1
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.8"
2
+ VERSION = "0.1.11"
3
3
  end
data/lib/muxr/window.rb CHANGED
@@ -14,7 +14,7 @@ module Muxr
14
14
  @focused_index = 0
15
15
  @last_focused_pane = nil
16
16
  @master_index = 0
17
- @layout = :tall
17
+ @layout = :spiral
18
18
  end
19
19
 
20
20
  # Setter records the outgoing focused pane (by reference) so focus_last can
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc