muxr 0.1.1 → 0.1.3

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: 61893be4e6b1d4a0ce7d3c785ffc60e45d5b272ede1ce267a751760cf81ee442
4
- data.tar.gz: d426522851cd327c560e7f384799749f6b575785a8e9b75ab15349494fa1edaa
3
+ metadata.gz: d19432b15ff535a2ce8f4bfb5b759ddd3599b4fb0af949d7871cf1059497c483
4
+ data.tar.gz: ce58f726a5a9dc186deb042c0d0e31fa8694676d0cdb4c496cfb57668d5fd4c1
5
5
  SHA512:
6
- metadata.gz: f511b20a929bb4eb65d8a804bcf51b113357ad921eed44b1c4305aade2d78de07f158b4a84c5916a83405709417df569cd213aca9c5422610d51fa7b2a4aeed0
7
- data.tar.gz: 357d7142c4087eae0833889893a9ed6080401ef7ba580f0d1fa7e8d0c94b8d69bb73a8f2f5ef76ee7397cfee1653c2eb11e8a38e00dff6f016ad9c0b8534a5c0
6
+ metadata.gz: 5b96c66aa1b96e853e7d268c012f8bc5e62fdf77c1e3d4e89360b764d4cfc1d20bdb271be81acad05f26417d09e17fe27f437680cb00a33fcf6f2c934600fef2
7
+ data.tar.gz: '045758a0d3ec1e4f2d6bfe4bb887743cc2ab80310bd842190b362e9a9976fd023b8818c35d13f80478839bbbb12d91372b22ddabc91ee430afda8c3ce3b0853a'
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.3] - 2026-05-11
10
+
11
+ ### Fixed
12
+ - Large pastes into a pane no longer hang the server. PTY writes are now
13
+ non-blocking and buffered per pane, with the writer fd added to the
14
+ event loop's `IO.select` write set so back-pressure from a slow reader
15
+ (e.g. Claude Code processing a multi-KB paste) can't deadlock the
16
+ single-threaded server. Idle pass-through input is also batched into a
17
+ single `send_to_focused` chunk instead of one call per byte.
18
+ - Client↔server socket writes are now non-blocking too, with per-side
19
+ outgoing buffers and the socket added to `IO.select`'s write set when
20
+ there's queued data. The previous blocking `Protocol.write` could
21
+ deadlock both ends when a paste produced enough redraw traffic to fill
22
+ both directions of the unix-socket kernel buffer at once (vim and
23
+ Claude Code both reproduced this).
24
+
25
+ ### Added
26
+ - Enable bracketed paste mode (`\e[?2004h`) on the outer terminal when
27
+ the client attaches. The terminal emulator now wraps pastes with
28
+ `\e[200~...\e[201~`, those markers flow through muxr to the focused
29
+ pane, and apps that opt in (Claude Code, vim, modern readline) again
30
+ recognise the input as a paste — Claude Code collapses it to
31
+ `[Pasted text +N lines]` instead of typing the whole thing out.
32
+
33
+ ## [0.1.2] - 2026-05-11
34
+
35
+ ### Added
36
+ - Vim-style word and viewport motions in copy-mode selection cursor:
37
+ `w`/`W`/`e`/`E`/`b`/`B` walk word and WORD boundaries, `^` jumps to
38
+ the first non-blank on the line, and `H`/`M`/`L` land on the visible
39
+ top/middle/bottom rows. Yanking now drops straight back to the live
40
+ shell (matching vim's `v…y` returning to normal mode); the tmux-style
41
+ `b` alias for page-back is now `Ctrl-b` only.
42
+
43
+ ### Fixed
44
+ - Honor SGR 2 (dim) so faint text actually renders faint. The emulator
45
+ was silently dropping the attribute, which left Claude Code's
46
+ suggested-prompt placeholder rendering at normal intensity. SGR 22
47
+ now correctly clears both bold and dim per spec.
48
+
9
49
  ## [0.1.1] - 2026-05-11
10
50
 
11
51
  ### Fixed
@@ -51,6 +91,7 @@ Initial release.
51
91
  boundaries.
52
92
  - Renderer that composes one frame and diff-emits ANSI to STDOUT.
53
93
 
54
- [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.1...HEAD
94
+ [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.2...HEAD
95
+ [0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
55
96
  [0.1.1]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.1
56
97
  [0.1.0]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.0
data/README.md CHANGED
@@ -20,6 +20,27 @@ the active layout decides geometry.
20
20
  [default] panes:3 layout:tall focused:#1 drawer:shown muxr ^a ?
21
21
  ```
22
22
 
23
+ ## Screenshots
24
+
25
+ The three built-in layouts (cycle with `C-a Tab`):
26
+
27
+ <table>
28
+ <tr>
29
+ <td align="center"><strong>tall</strong><br/>master + stacked slaves</td>
30
+ <td align="center"><strong>grid</strong><br/>even tiling</td>
31
+ <td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
32
+ </tr>
33
+ <tr>
34
+ <td><img src="docs/screenshots/01-layout-tall.png" alt="tall layout"></td>
35
+ <td><img src="docs/screenshots/02-layout-grid.png" alt="grid layout"></td>
36
+ <td><img src="docs/screenshots/03-layout-monocle.png" alt="monocle layout"></td>
37
+ </tr>
38
+ </table>
39
+
40
+ The Quake-style drawer overlay (`C-a ~`):
41
+
42
+ ![drawer overlay](docs/screenshots/04-drawer.png)
43
+
23
44
  ## Install / run
24
45
 
25
46
  ```bash
@@ -70,17 +91,33 @@ the pane title gains `[scrollback N/M]`.
70
91
  |-------------------------|-------------------------------------|
71
92
  | `j` / `k` | scroll one line |
72
93
  | `d` / `u` (or `C-d`/`C-u`) | half page |
73
- | `f` / `b` / Space (or `C-f`/`C-b`) | full page |
94
+ | `f` / Space (or `C-f`/`C-b`) | full page |
74
95
  | `g` / `G` | top / bottom |
75
96
  | `q` / `Esc` / `C-c` | exit back to live view |
76
97
 
77
- Press `v` inside scrollback to enter a movable-cursor selection mode
78
- (`h j k l`, `0`/`$`, `g`/`G`, `C-d`/`C-u`, `C-f`/`C-b`, Space). Press `v`
79
- again to anchor a character selection or `C-v` to anchor a block
80
- (rectangular) selection — toggling between the two preserves the anchor.
81
- `y` or Enter yanks the selection into an internal buffer and pipes it to
82
- `pbcopy` in the background (silent no-op when `pbcopy` is unavailable).
83
- `C-a ]` writes the yank buffer back into the focused pane.
98
+ Press `v` inside scrollback to enter a movable-cursor selection mode.
99
+ Vim-style motions are supported:
100
+
101
+ | Keys | Action |
102
+ |-------------------------|-------------------------------------|
103
+ | `h` / `j` / `k` / `l` | left / down / up / right |
104
+ | `0` / `^` / `$` | line start / first non-blank / line end |
105
+ | `w` / `W` | next word / WORD start |
106
+ | `e` / `E` | next word / WORD end |
107
+ | `b` / `B` | previous word / WORD start |
108
+ | `g` / `G` | top / bottom of timeline |
109
+ | `H` / `M` / `L` | top / middle / bottom of viewport |
110
+ | `C-d`/`C-u`, `C-f`/`C-b`, Space | half / full page |
111
+ | `v` / `C-v` | anchor char / block selection (toggle) |
112
+ | `y` or Enter | yank and exit to live shell |
113
+ | `q` / `Esc` / `C-c` | cancel back to scrollback |
114
+
115
+ `v` and `C-v` toggle between character and block (rectangular) selection
116
+ — switching between the two preserves the anchor. `y` or Enter yanks the
117
+ 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.
84
121
 
85
122
  ## Commands (typed after `C-a :`)
86
123
 
@@ -57,6 +57,7 @@ module Muxr
57
57
  @help_visible = false
58
58
  @next_pane_id = 0
59
59
  @current_client = nil
60
+ @client_write_buffer = +"".b
60
61
  @listening_socket = nil
61
62
  @socket_path = self.class.socket_path_for(@session_name)
62
63
  @paste_buffer = +""
@@ -302,6 +303,7 @@ module Muxr
302
303
  def exit_selection(yank:)
303
304
  target = focused_target
304
305
  term = target&.terminal
306
+ yanked = false
305
307
  if yank
306
308
  # No anchor → no-op. User is still positioning; they can press v
307
309
  # first, then yank. Esc/q is the way to exit from navigation.
@@ -311,10 +313,18 @@ module Muxr
311
313
  @paste_buffer = text
312
314
  spawn_pbcopy(text)
313
315
  flash("yanked #{text.bytesize} bytes")
316
+ yanked = true
314
317
  end
315
318
  end
316
319
  term&.clear_selection
317
- @input.enter_scrollback_mode
320
+ if yanked
321
+ # vim-style: yanking drops you straight back to "normal" (idle),
322
+ # not back into scrollback navigation.
323
+ term&.scroll_to_bottom
324
+ @input.enter_idle_mode
325
+ else
326
+ @input.enter_scrollback_mode
327
+ end
318
328
  @renderer.reset_frame!
319
329
  invalidate
320
330
  end
@@ -336,8 +346,18 @@ module Muxr
336
346
  when :full_down then term.move_selection_cursor_by([rows - 1, 1].max, 0)
337
347
  when :line_start then term.selection_cursor_to_line_start
338
348
  when :line_end then term.selection_cursor_to_line_end
339
- when :top then term.selection_cursor_to_top
340
- when :bottom then term.selection_cursor_to_bottom
349
+ when :line_first_nonblank then term.selection_cursor_to_first_non_blank
350
+ when :top then term.selection_cursor_to_top
351
+ when :bottom then term.selection_cursor_to_bottom
352
+ when :screen_top then term.selection_cursor_to_viewport(:top)
353
+ when :screen_middle then term.selection_cursor_to_viewport(:middle)
354
+ when :screen_bottom then term.selection_cursor_to_viewport(:bottom)
355
+ when :word_forward then term.selection_cursor_word_forward(big: false)
356
+ when :word_forward_big then term.selection_cursor_word_forward(big: true)
357
+ when :word_end then term.selection_cursor_word_end(big: false)
358
+ when :word_end_big then term.selection_cursor_word_end(big: true)
359
+ when :word_backward then term.selection_cursor_word_backward(big: false)
360
+ when :word_backward_big then term.selection_cursor_word_backward(big: true)
341
361
  end
342
362
  invalidate
343
363
  end
@@ -382,12 +402,29 @@ module Muxr
382
402
  end
383
403
  end
384
404
 
385
- # Called by the FramedOutput adapter; ships one OUTPUT frame to the
386
- # currently attached client. No-op when nobody is attached.
405
+ # Called by the FramedOutput adapter; queues one OUTPUT frame to the
406
+ # currently attached client and tries to push as much as the socket
407
+ # will take without blocking. Anything left over stays in
408
+ # @client_write_buffer and gets flushed by the event loop when the
409
+ # socket reports writable. This prevents a slow client (or slow
410
+ # terminal upstream of the client) from deadlocking the server when
411
+ # the server is also trying to read from that same client.
387
412
  def deliver_output(bytes)
388
- sock = @current_client
389
- return unless sock
390
- Protocol.write(sock, Protocol::OUTPUT, bytes)
413
+ return unless @current_client
414
+ @client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
415
+ drain_client_writes
416
+ end
417
+
418
+ def drain_client_writes
419
+ return unless @current_client
420
+ return if @client_write_buffer.empty?
421
+ loop do
422
+ n = @current_client.write_nonblock(@client_write_buffer)
423
+ @client_write_buffer = @client_write_buffer.byteslice(n..-1) || +"".b
424
+ break if @client_write_buffer.empty?
425
+ end
426
+ rescue IO::WaitWritable
427
+ # Socket send buffer is full; the rest stays queued.
391
428
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
392
429
  drop_client_silently
393
430
  end
@@ -460,25 +497,40 @@ module Muxr
460
497
 
461
498
  def loop_forever
462
499
  while @running
463
- ready_ios = [@listening_socket]
464
- ready_ios << @current_client if @current_client
465
- @session.window.panes.each { |p| ready_ios << p.io if p.alive? }
466
- if @session.drawer&.pane && @session.drawer.pane.alive?
467
- ready_ios << @session.drawer.pane.io
500
+ read_ios = [@listening_socket]
501
+ read_ios << @current_client if @current_client
502
+ @session.window.panes.each { |p| read_ios << p.io if p.alive? }
503
+ drawer_pane = @session.drawer&.pane
504
+ read_ios << drawer_pane.io if drawer_pane&.alive?
505
+
506
+ write_ios = []
507
+ @session.window.panes.each do |p|
508
+ write_ios << p.writer_io if p.alive? && p.pending_write?
468
509
  end
510
+ if drawer_pane&.alive? && drawer_pane.pending_write?
511
+ write_ios << drawer_pane.writer_io
512
+ end
513
+ write_ios << @current_client if @current_client && !@client_write_buffer.empty?
469
514
 
470
515
  timeout = @message ? 0.25 : SELECT_TIMEOUT
471
- ready, = IO.select(ready_ios, nil, nil, timeout)
472
-
473
- if ready
474
- ready.each do |io|
475
- if io == @listening_socket
476
- accept_client
477
- elsif io == @current_client
478
- consume_client_frame
479
- else
480
- consume_pane_io(io)
481
- end
516
+ ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
517
+
518
+ ready_r&.each do |io|
519
+ if io == @listening_socket
520
+ accept_client
521
+ elsif io == @current_client
522
+ consume_client_frame
523
+ else
524
+ consume_pane_io(io)
525
+ end
526
+ end
527
+
528
+ ready_w&.each do |io|
529
+ if io == @current_client
530
+ drain_client_writes
531
+ else
532
+ pane = pane_for_writer_io(io)
533
+ pane&.drain_writes
482
534
  end
483
535
  end
484
536
 
@@ -560,6 +612,13 @@ module Muxr
560
612
  nil
561
613
  end
562
614
 
615
+ def pane_for_writer_io(io)
616
+ pane = @session.window.panes.find { |p| p.writer_io == io }
617
+ return pane if pane
618
+ return @session.drawer.pane if @session.drawer&.pane && @session.drawer.pane.writer_io == io
619
+ nil
620
+ end
621
+
563
622
  def prune_dead_panes
564
623
  dead = @session.window.panes.reject(&:alive?)
565
624
  return if dead.empty?
@@ -593,6 +652,11 @@ module Muxr
593
652
 
594
653
  def disconnect_client(reason: nil)
595
654
  return unless @current_client
655
+ # Best-effort: drop any queued OUTPUT (the client is going away),
656
+ # send a final BYE, then close. BYE is small enough that one
657
+ # blocking write won't meaningfully wedge anything even if the
658
+ # client's recv is sluggish.
659
+ @client_write_buffer = +"".b
596
660
  safe_protocol_write(@current_client, Protocol::BYE, reason || "")
597
661
  @current_client.close rescue nil
598
662
  @current_client = nil
@@ -602,6 +666,7 @@ module Muxr
602
666
  return unless @current_client
603
667
  @current_client.close rescue nil
604
668
  @current_client = nil
669
+ @client_write_buffer = +"".b
605
670
  end
606
671
 
607
672
  def safe_protocol_write(io, type, payload = "")
data/lib/muxr/client.rb CHANGED
@@ -24,6 +24,7 @@ module Muxr
24
24
  @resize_pending = false
25
25
  @exit_code = 0
26
26
  @bye_reason = nil
27
+ @write_buffer = +"".b
27
28
  end
28
29
 
29
30
  # Opens the socket. Returns true on success. Raises Errno::ENOENT /
@@ -62,28 +63,55 @@ module Muxr
62
63
  send_resize
63
64
  end
64
65
 
65
- ready, = IO.select([STDIN, @sock], nil, nil, SELECT_TIMEOUT)
66
- next unless ready
66
+ write_ios = @write_buffer.empty? ? nil : [@sock]
67
+ ready_r, ready_w, = IO.select([STDIN, @sock], write_ios, nil, SELECT_TIMEOUT)
68
+ next unless ready_r || ready_w
67
69
 
68
- ready.each do |io|
70
+ ready_r&.each do |io|
69
71
  if io == STDIN
70
72
  forward_stdin
71
73
  else
72
74
  consume_server_frame
73
75
  end
74
76
  end
77
+
78
+ drain_writes if ready_w&.include?(@sock)
75
79
  end
76
80
  end
77
81
 
78
82
  def forward_stdin
79
83
  data = STDIN.read_nonblock(4096)
80
- Protocol.write(@sock, Protocol::INPUT, data)
84
+ queue_frame(Protocol::INPUT, data)
81
85
  rescue IO::WaitReadable
82
86
  # spurious wake-up; nothing to do.
83
87
  rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
84
88
  @running = false
85
89
  end
86
90
 
91
+ def queue_frame(type, payload)
92
+ @write_buffer << Protocol.frame(type, payload)
93
+ drain_writes
94
+ end
95
+
96
+ # Push as much of @write_buffer to the server as the socket will
97
+ # accept without blocking. Anything left over stays queued and the
98
+ # event loop picks it back up when select reports the socket
99
+ # writable. Mirrors the server-side OUTPUT drain — without it, a
100
+ # busy server (rendering large vim/Claude-code redraws) could fill
101
+ # this socket's send buffer and wedge the client mid-paste.
102
+ def drain_writes
103
+ return if @write_buffer.empty?
104
+ loop do
105
+ n = @sock.write_nonblock(@write_buffer)
106
+ @write_buffer = @write_buffer.byteslice(n..-1) || +"".b
107
+ break if @write_buffer.empty?
108
+ end
109
+ rescue IO::WaitWritable
110
+ # Socket send buffer full; remainder stays queued.
111
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
112
+ @running = false
113
+ end
114
+
87
115
  def consume_server_frame
88
116
  type, payload = Protocol.read(@sock)
89
117
  if type.nil?
@@ -106,7 +134,7 @@ module Muxr
106
134
 
107
135
  def send_resize
108
136
  rows, cols = terminal_size
109
- Protocol.write(@sock, Protocol::RESIZE, Protocol.encode_size(rows, cols))
137
+ queue_frame(Protocol::RESIZE, Protocol.encode_size(rows, cols))
110
138
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
111
139
  @running = false
112
140
  end
@@ -124,12 +152,17 @@ module Muxr
124
152
  def enter_terminal_mode
125
153
  STDIN.raw!
126
154
  STDIN.echo = false
127
- STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m")
155
+ # Enable bracketed paste on the outer terminal so iTerm/Terminal/etc.
156
+ # wrap pastes with \e[200~...\e[201~. Those markers flow through
157
+ # untouched to the focused pane's PTY, which lets Claude Code, vim,
158
+ # modern bash, etc. recognise the input as a paste and collapse it
159
+ # instead of typing it out character-by-character.
160
+ STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e[?2004h")
128
161
  STDOUT.flush
129
162
  end
130
163
 
131
164
  def leave_terminal_mode
132
- STDOUT.write("\e[0m\e[?25h\e[?1049l")
165
+ STDOUT.write("\e[?2004l\e[0m\e[?25h\e[?1049l")
133
166
  STDOUT.flush
134
167
  begin
135
168
  STDIN.cooked!
@@ -48,8 +48,19 @@ module Muxr
48
48
  "k" => :up,
49
49
  "0" => :line_start,
50
50
  "$" => :line_end,
51
+ "^" => :line_first_nonblank,
51
52
  "g" => :top,
52
53
  "G" => :bottom,
54
+ "H" => :screen_top,
55
+ "M" => :screen_middle,
56
+ "L" => :screen_bottom,
57
+ "w" => :word_forward,
58
+ "W" => :word_forward_big,
59
+ "e" => :word_end,
60
+ "E" => :word_end_big,
61
+ # `b` is vim word-back here; the tmux-style page-back alias lives on Ctrl-b.
62
+ "b" => :word_backward,
63
+ "B" => :word_backward_big,
53
64
  "\x04" => :half_down, # Ctrl-d
54
65
  "\x15" => :half_up, # Ctrl-u
55
66
  "d" => :half_down,
@@ -57,7 +68,6 @@ module Muxr
57
68
  "\x06" => :full_down, # Ctrl-f
58
69
  "\x02" => :full_up, # Ctrl-b
59
70
  "f" => :full_down,
60
- "b" => :full_up,
61
71
  " " => :full_down
62
72
  }.freeze
63
73
 
@@ -75,19 +85,32 @@ module Muxr
75
85
  end
76
86
 
77
87
  def feed(data)
78
- data.each_char do |ch|
88
+ remaining = data
89
+ until remaining.empty?
90
+ if @state == :idle
91
+ # Fast path for pass-through: forward everything up to the next
92
+ # Ctrl-a as a single chunk so a large paste doesn't turn into one
93
+ # PTY write per byte. PREFIX is single-byte ASCII (\x01) and never
94
+ # appears mid-UTF-8, so byte/char index match.
95
+ idx = remaining.index(PREFIX)
96
+ if idx.nil?
97
+ @app.send_to_focused(remaining)
98
+ return
99
+ end
100
+ @app.send_to_focused(remaining[0...idx]) if idx > 0
101
+ @state = :prefix
102
+ remaining = remaining[(idx + 1)..] || ""
103
+ next
104
+ end
105
+
106
+ ch = remaining[0]
107
+ remaining = remaining[1..] || ""
79
108
  case @state
80
109
  when :help
81
110
  @app.dismiss_help
82
111
  @state = :idle
83
112
  when :confirm_quit
84
113
  handle_confirm_quit(ch)
85
- when :idle
86
- if ch == PREFIX
87
- @state = :prefix
88
- else
89
- @app.send_to_focused(ch)
90
- end
91
114
  when :prefix
92
115
  handle_prefix(ch)
93
116
  when :command
data/lib/muxr/pane.rb CHANGED
@@ -20,6 +20,18 @@ module Muxr
20
20
  @process.io
21
21
  end
22
22
 
23
+ def writer_io
24
+ @process.writer_io
25
+ end
26
+
27
+ def pending_write?
28
+ @process.pending_write?
29
+ end
30
+
31
+ def drain_writes
32
+ @process.drain
33
+ end
34
+
23
35
  def write(data)
24
36
  @process.write(data)
25
37
  end
data/lib/muxr/protocol.rb CHANGED
@@ -39,9 +39,20 @@ module Muxr
39
39
 
40
40
  # Writes one framed message. +payload+ is treated as raw bytes (binary).
41
41
  def self.write(io, type, payload = "")
42
+ io.write(frame(type, payload))
43
+ end
44
+
45
+ # Builds the on-the-wire bytes for a single frame without writing them.
46
+ # Lets callers append to an outgoing buffer (for non-blocking flushes
47
+ # later) instead of doing a synchronous io.write.
48
+ def self.frame(type, payload = "")
42
49
  raise ArgumentError, "type must be a single byte" unless type.is_a?(String) && type.bytesize == 1
43
50
  bytes = payload.to_s.b
44
- io.write(type + [bytes.bytesize].pack("N") + bytes)
51
+ buf = +"".b
52
+ buf << type.b
53
+ buf << [bytes.bytesize].pack("N")
54
+ buf << bytes
55
+ buf
45
56
  end
46
57
 
47
58
  # Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
@@ -10,6 +10,7 @@ module Muxr
10
10
  @rows = rows
11
11
  @cols = cols
12
12
  @exited = false
13
+ @write_buffer = +"".b
13
14
 
14
15
  shell = command || ENV["SHELL"] || "/bin/sh"
15
16
  env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
@@ -23,11 +24,42 @@ module Muxr
23
24
  resize(rows, cols)
24
25
  end
25
26
 
27
+ # Appends bytes to the per-process outgoing buffer and tries to flush as
28
+ # many as the PTY will accept right now. Any remainder stays buffered;
29
+ # the event loop drains it later when the writer fd is reported writable.
30
+ # This avoids deadlocking the single-threaded server when the inner
31
+ # program is slow to read (large pastes were the original motivating
32
+ # case — see CHANGELOG 0.1.3).
26
33
  def write(data)
27
- @writer.write(data)
28
- @writer.flush
34
+ return if @exited
35
+ return if data.nil? || data.empty?
36
+ @write_buffer << data.b
37
+ drain
38
+ end
39
+
40
+ # Push as much of @write_buffer through the PTY as it'll take without
41
+ # blocking. Safe to call repeatedly — both from write() and from the
42
+ # event loop when select reports the writer fd writable.
43
+ def drain
44
+ return if @exited || @write_buffer.empty?
45
+ loop do
46
+ n = @writer.write_nonblock(@write_buffer)
47
+ @write_buffer = @write_buffer.byteslice(n..-1) || +"".b
48
+ break if @write_buffer.empty?
49
+ end
50
+ rescue IO::WaitWritable
51
+ # Kernel buffer full; the rest stays queued.
29
52
  rescue Errno::EIO, IOError, Errno::EPIPE
30
53
  @exited = true
54
+ @write_buffer.clear
55
+ end
56
+
57
+ def pending_write?
58
+ !@write_buffer.empty?
59
+ end
60
+
61
+ def writer_io
62
+ @writer
31
63
  end
32
64
 
33
65
  def read_nonblock(max = 8192)
data/lib/muxr/renderer.rb CHANGED
@@ -264,8 +264,9 @@ module Muxr
264
264
  " C-a Tab cycle layout (tall → grid → monocle)",
265
265
  " C-a Enter promote focused pane to master",
266
266
  " C-a ~ toggle drawer",
267
- " C-a [ enter scrollback (j/k d/u f/b g/G; v→cursor, q quits)",
268
- " in cursor mode: v char-select, C-v block-select, y yank",
267
+ " C-a [ enter scrollback (j/k d/u f g/G C-b/C-f; v→cursor, q quits)",
268
+ " cursor: v select, C-v block, y yank, q cancel",
269
+ " motions: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
269
270
  " C-a ] paste internal copy buffer",
270
271
  " C-a d detach (server keeps running)",
271
272
  " C-a q kill session (asks y/n)",
@@ -439,6 +440,7 @@ module Muxr
439
440
  parts = ["0"]
440
441
  attrs = cell.attrs.to_i
441
442
  parts << "1" if (attrs & Terminal::BOLD) != 0
443
+ parts << "2" if (attrs & Terminal::DIM) != 0
442
444
  parts << "4" if (attrs & Terminal::UNDERLINE) != 0
443
445
  parts << "7" if (attrs & Terminal::REVERSE) != 0
444
446
  append_color(parts, cell.fg, true)
data/lib/muxr/terminal.rb CHANGED
@@ -8,6 +8,7 @@ module Muxr
8
8
  BOLD = 1
9
9
  UNDERLINE = 2
10
10
  REVERSE = 4
11
+ DIM = 8
11
12
 
12
13
  SCROLLBACK_MAX = 5000
13
14
 
@@ -183,6 +184,96 @@ module Muxr
183
184
  selection_cursor_to(timeline_size - 1, @cols - 1)
184
185
  end
185
186
 
187
+ def selection_cursor_to_first_non_blank
188
+ return unless @selection_cursor
189
+ tr = @selection_cursor[0]
190
+ selection_cursor_to(tr, first_non_blank_col(tr))
191
+ end
192
+
193
+ # Jump to top/middle/bottom of the visible viewport (vim H/M/L), landing
194
+ # on the first non-blank column of the destination line.
195
+ def selection_cursor_to_viewport(where)
196
+ return unless @selection_cursor
197
+ vr = case where
198
+ when :top then 0
199
+ when :middle then @rows / 2
200
+ when :bottom then @rows - 1
201
+ end
202
+ return if vr.nil?
203
+ tr = timeline_row_for_visible(vr).clamp(0, timeline_size - 1)
204
+ selection_cursor_to(tr, first_non_blank_col(tr))
205
+ end
206
+
207
+ def selection_cursor_word_forward(big: false)
208
+ return unless @selection_cursor
209
+ tr, tc = @selection_cursor
210
+ prev_cls = char_class_at(tr, tc, big: big)
211
+ loop do
212
+ nxt = step_forward(tr, tc)
213
+ break unless nxt
214
+ ntr, ntc = nxt
215
+ cur_cls = char_class_at(ntr, ntc, big: big)
216
+ # Row boundaries act as whitespace breaks even when the row is fully
217
+ # packed (no trailing pad) — visually the user sees a new line.
218
+ effective_prev = (ntr != tr) ? :space : prev_cls
219
+ if effective_prev != cur_cls && cur_cls != :space
220
+ selection_cursor_to(ntr, ntc)
221
+ return
222
+ end
223
+ tr, tc = ntr, ntc
224
+ prev_cls = cur_cls
225
+ end
226
+ selection_cursor_to(timeline_size - 1, @cols - 1)
227
+ end
228
+
229
+ def selection_cursor_word_end(big: false)
230
+ return unless @selection_cursor
231
+ tr, tc = @selection_cursor
232
+ pos = step_forward(tr, tc)
233
+ return unless pos
234
+ tr, tc = pos
235
+ while char_class_at(tr, tc, big: big) == :space
236
+ pos = step_forward(tr, tc)
237
+ break unless pos
238
+ tr, tc = pos
239
+ end
240
+ return if char_class_at(tr, tc, big: big) == :space
241
+ cls = char_class_at(tr, tc, big: big)
242
+ loop do
243
+ pos = step_forward(tr, tc)
244
+ if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
245
+ selection_cursor_to(tr, tc)
246
+ return
247
+ end
248
+ tr, tc = pos
249
+ end
250
+ end
251
+
252
+ def selection_cursor_word_backward(big: false)
253
+ return unless @selection_cursor
254
+ tr, tc = @selection_cursor
255
+ pos = step_backward(tr, tc)
256
+ return unless pos
257
+ tr, tc = pos
258
+ while char_class_at(tr, tc, big: big) == :space
259
+ pos = step_backward(tr, tc)
260
+ unless pos
261
+ selection_cursor_to(tr, tc)
262
+ return
263
+ end
264
+ tr, tc = pos
265
+ end
266
+ cls = char_class_at(tr, tc, big: big)
267
+ loop do
268
+ pos = step_backward(tr, tc)
269
+ if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
270
+ selection_cursor_to(tr, tc)
271
+ return
272
+ end
273
+ tr, tc = pos
274
+ end
275
+ end
276
+
186
277
  def clear_selection
187
278
  return unless @selection_anchor
188
279
  @selection_anchor = nil
@@ -594,6 +685,44 @@ module Muxr
594
685
  @scrollback.size - @view_offset + r
595
686
  end
596
687
 
688
+ def first_non_blank_col(tr)
689
+ row = timeline_row(tr)
690
+ return 0 unless row
691
+ @cols.times do |c|
692
+ ch = row[c]&.char
693
+ return c if ch && ch != " " && ch != "\t"
694
+ end
695
+ 0
696
+ end
697
+
698
+ def char_class_at(tr, tc, big:)
699
+ row = timeline_row(tr)
700
+ classify_char(row && row[tc] && row[tc].char, big: big)
701
+ end
702
+
703
+ # vim "word" = run of \w (alnum + _); "WORD" = any run of non-whitespace.
704
+ def classify_char(ch, big:)
705
+ return :space if ch.nil? || ch == " " || ch == "\t" || ch == ""
706
+ return :word if big
707
+ ch.match?(/\A\w\z/) ? :word : :punct
708
+ end
709
+
710
+ def step_forward(tr, tc)
711
+ if tc + 1 < @cols
712
+ [tr, tc + 1]
713
+ elsif tr + 1 < timeline_size
714
+ [tr + 1, 0]
715
+ end
716
+ end
717
+
718
+ def step_backward(tr, tc)
719
+ if tc > 0
720
+ [tr, tc - 1]
721
+ elsif tr > 0
722
+ [tr - 1, @cols - 1]
723
+ end
724
+ end
725
+
597
726
  def ordered_selection
598
727
  a = @selection_anchor
599
728
  b = @selection_cursor
@@ -719,9 +848,10 @@ module Muxr
719
848
  @bg = nil
720
849
  @attrs = 0
721
850
  when 1 then @attrs |= BOLD
851
+ when 2 then @attrs |= DIM
722
852
  when 4 then @attrs |= UNDERLINE
723
853
  when 7 then @attrs |= REVERSE
724
- when 22 then @attrs &= ~BOLD
854
+ when 22 then @attrs &= ~(BOLD | DIM)
725
855
  when 24 then @attrs &= ~UNDERLINE
726
856
  when 27 then @attrs &= ~REVERSE
727
857
  when 30..37 then @fg = p - 30
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.3"
3
3
  end
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.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc