muxr 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/lib/muxr/application.rb +115 -24
- data/lib/muxr/client.rb +40 -7
- data/lib/muxr/input_handler.rb +20 -7
- data/lib/muxr/pane.rb +27 -4
- data/lib/muxr/protocol.rb +12 -1
- data/lib/muxr/pty_process.rb +34 -2
- data/lib/muxr/renderer.rb +8 -1
- data/lib/muxr/terminal.rb +43 -2
- data/lib/muxr/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
|
|
4
|
+
data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9139072cdc6e55e0ab94c7144d492858cdb3fa75bf59f5009b925d7f4e6659d8228f64987aba7c08fdff119dbcbb480511969cc64c0904e96a3ae95cb8456ddc
|
|
7
|
+
data.tar.gz: 77028f21adf1d5a7710f952af3dfc0621223b4aa0cbb70ff15436c54e797d8d3809885b50d31a71c5f417afaae2bb153bbf149379110813beeb23ba716cefe25
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,47 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.4] - 2026-05-13
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Render flicker on large screens and during fzf-style redraws. Each
|
|
13
|
+
Renderer frame is now wrapped in DEC 2026 synchronized output (with
|
|
14
|
+
the cursor hidden for the duration of the diff), so terminals that
|
|
15
|
+
support it (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13,
|
|
16
|
+
foot) present the frame atomically instead of repainting cell by
|
|
17
|
+
cell. `Pane#read_from_pty` now drains the PTY to `EAGAIN` per tick,
|
|
18
|
+
collapsing multi-chunk bursts (vim cursor+status redraw, fzf
|
|
19
|
+
candidate list) into a single render, and the event loop caps
|
|
20
|
+
repaints at ~60 Hz while trimming `IO.select`'s timeout so deferred
|
|
21
|
+
frames still land on time. The Terminal emulator also honors
|
|
22
|
+
`\e[?2026h` / `\e[?2026l` from inner programs (fzf ≥0.41, neovim,
|
|
23
|
+
helix) as a render-timing hint — the outer paint is held until the
|
|
24
|
+
close sequence arrives or a 200 ms safety timeout expires.
|
|
25
|
+
|
|
26
|
+
## [0.1.3] - 2026-05-11
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Large pastes into a pane no longer hang the server. PTY writes are now
|
|
30
|
+
non-blocking and buffered per pane, with the writer fd added to the
|
|
31
|
+
event loop's `IO.select` write set so back-pressure from a slow reader
|
|
32
|
+
(e.g. Claude Code processing a multi-KB paste) can't deadlock the
|
|
33
|
+
single-threaded server. Idle pass-through input is also batched into a
|
|
34
|
+
single `send_to_focused` chunk instead of one call per byte.
|
|
35
|
+
- Client↔server socket writes are now non-blocking too, with per-side
|
|
36
|
+
outgoing buffers and the socket added to `IO.select`'s write set when
|
|
37
|
+
there's queued data. The previous blocking `Protocol.write` could
|
|
38
|
+
deadlock both ends when a paste produced enough redraw traffic to fill
|
|
39
|
+
both directions of the unix-socket kernel buffer at once (vim and
|
|
40
|
+
Claude Code both reproduced this).
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- Enable bracketed paste mode (`\e[?2004h`) on the outer terminal when
|
|
44
|
+
the client attaches. The terminal emulator now wraps pastes with
|
|
45
|
+
`\e[200~...\e[201~`, those markers flow through muxr to the focused
|
|
46
|
+
pane, and apps that opt in (Claude Code, vim, modern readline) again
|
|
47
|
+
recognise the input as a paste — Claude Code collapses it to
|
|
48
|
+
`[Pasted text +N lines]` instead of typing the whole thing out.
|
|
49
|
+
|
|
9
50
|
## [0.1.2] - 2026-05-11
|
|
10
51
|
|
|
11
52
|
### Added
|
|
@@ -67,7 +108,9 @@ Initial release.
|
|
|
67
108
|
boundaries.
|
|
68
109
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
69
110
|
|
|
70
|
-
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.
|
|
111
|
+
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.4...HEAD
|
|
112
|
+
[0.1.4]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.4
|
|
113
|
+
[0.1.3]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.3
|
|
71
114
|
[0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
|
|
72
115
|
[0.1.1]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.1
|
|
73
116
|
[0.1.0]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.0
|
data/lib/muxr/application.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Muxr
|
|
|
16
16
|
# attach via Renderer#reset_frame!.
|
|
17
17
|
class Application
|
|
18
18
|
SELECT_TIMEOUT = 0.05
|
|
19
|
+
# ~60 Hz cap on full repaints. Keystrokes in fzf or vim navigation can
|
|
20
|
+
# trigger PTY bursts faster than the terminal can usefully display them;
|
|
21
|
+
# the cap collapses those bursts and stops intermediate frames from
|
|
22
|
+
# showing through.
|
|
23
|
+
MIN_FRAME_INTERVAL = 1.0 / 60
|
|
19
24
|
SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
|
|
20
25
|
DEFAULT_WIDTH = 80
|
|
21
26
|
DEFAULT_HEIGHT = 24
|
|
@@ -57,9 +62,11 @@ module Muxr
|
|
|
57
62
|
@help_visible = false
|
|
58
63
|
@next_pane_id = 0
|
|
59
64
|
@current_client = nil
|
|
65
|
+
@client_write_buffer = +"".b
|
|
60
66
|
@listening_socket = nil
|
|
61
67
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
62
68
|
@paste_buffer = +""
|
|
69
|
+
@last_render_at = nil
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
attr_reader :paste_buffer
|
|
@@ -401,12 +408,29 @@ module Muxr
|
|
|
401
408
|
end
|
|
402
409
|
end
|
|
403
410
|
|
|
404
|
-
# Called by the FramedOutput adapter;
|
|
405
|
-
# currently attached client
|
|
411
|
+
# Called by the FramedOutput adapter; queues one OUTPUT frame to the
|
|
412
|
+
# currently attached client and tries to push as much as the socket
|
|
413
|
+
# will take without blocking. Anything left over stays in
|
|
414
|
+
# @client_write_buffer and gets flushed by the event loop when the
|
|
415
|
+
# socket reports writable. This prevents a slow client (or slow
|
|
416
|
+
# terminal upstream of the client) from deadlocking the server when
|
|
417
|
+
# the server is also trying to read from that same client.
|
|
406
418
|
def deliver_output(bytes)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
419
|
+
return unless @current_client
|
|
420
|
+
@client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
|
|
421
|
+
drain_client_writes
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def drain_client_writes
|
|
425
|
+
return unless @current_client
|
|
426
|
+
return if @client_write_buffer.empty?
|
|
427
|
+
loop do
|
|
428
|
+
n = @current_client.write_nonblock(@client_write_buffer)
|
|
429
|
+
@client_write_buffer = @client_write_buffer.byteslice(n..-1) || +"".b
|
|
430
|
+
break if @client_write_buffer.empty?
|
|
431
|
+
end
|
|
432
|
+
rescue IO::WaitWritable
|
|
433
|
+
# Socket send buffer is full; the rest stays queued.
|
|
410
434
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
411
435
|
drop_client_silently
|
|
412
436
|
end
|
|
@@ -479,25 +503,54 @@ module Muxr
|
|
|
479
503
|
|
|
480
504
|
def loop_forever
|
|
481
505
|
while @running
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
@session.window.panes.each { |p|
|
|
485
|
-
|
|
486
|
-
|
|
506
|
+
read_ios = [@listening_socket]
|
|
507
|
+
read_ios << @current_client if @current_client
|
|
508
|
+
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
509
|
+
drawer_pane = @session.drawer&.pane
|
|
510
|
+
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
511
|
+
|
|
512
|
+
write_ios = []
|
|
513
|
+
@session.window.panes.each do |p|
|
|
514
|
+
write_ios << p.writer_io if p.alive? && p.pending_write?
|
|
515
|
+
end
|
|
516
|
+
if drawer_pane&.alive? && drawer_pane.pending_write?
|
|
517
|
+
write_ios << drawer_pane.writer_io
|
|
487
518
|
end
|
|
519
|
+
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
488
520
|
|
|
489
521
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
522
|
+
# If a render is queued but we're inside the frame-rate budget, wake
|
|
523
|
+
# up as soon as the budget expires so the deferred paint lands on time.
|
|
524
|
+
if @current_client && @needs_render && @last_render_at
|
|
525
|
+
budget = MIN_FRAME_INTERVAL - (monotonic_now - @last_render_at)
|
|
526
|
+
timeout = budget.clamp(0, timeout) if budget < timeout
|
|
527
|
+
end
|
|
528
|
+
# If a pane is mid-synchronized-output (DEC 2026), wake up no later
|
|
529
|
+
# than its safety deadline so a crashed inner program can't wedge
|
|
530
|
+
# rendering past Terminal::SYNC_TIMEOUT.
|
|
531
|
+
deadline = nearest_sync_deadline
|
|
532
|
+
if deadline
|
|
533
|
+
remaining = deadline - monotonic_now
|
|
534
|
+
timeout = remaining.clamp(0, timeout) if remaining < timeout
|
|
535
|
+
end
|
|
536
|
+
ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
|
|
537
|
+
|
|
538
|
+
ready_r&.each do |io|
|
|
539
|
+
if io == @listening_socket
|
|
540
|
+
accept_client
|
|
541
|
+
elsif io == @current_client
|
|
542
|
+
consume_client_frame
|
|
543
|
+
else
|
|
544
|
+
consume_pane_io(io)
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
ready_w&.each do |io|
|
|
549
|
+
if io == @current_client
|
|
550
|
+
drain_client_writes
|
|
551
|
+
else
|
|
552
|
+
pane = pane_for_writer_io(io)
|
|
553
|
+
pane&.drain_writes
|
|
501
554
|
end
|
|
502
555
|
end
|
|
503
556
|
|
|
@@ -509,13 +562,38 @@ module Muxr
|
|
|
509
562
|
break
|
|
510
563
|
end
|
|
511
564
|
|
|
512
|
-
if @current_client && @needs_render
|
|
513
|
-
|
|
514
|
-
@
|
|
565
|
+
if @current_client && @needs_render && !any_pane_syncing?
|
|
566
|
+
now = monotonic_now
|
|
567
|
+
if @last_render_at.nil? || (now - @last_render_at) >= MIN_FRAME_INTERVAL
|
|
568
|
+
render
|
|
569
|
+
@last_render_at = now
|
|
570
|
+
@needs_render = false
|
|
571
|
+
end
|
|
515
572
|
end
|
|
516
573
|
end
|
|
517
574
|
end
|
|
518
575
|
|
|
576
|
+
def monotonic_now
|
|
577
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# True iff any pane (or the drawer) has opened a DEC 2026 synchronized
|
|
581
|
+
# output block that hasn't yet closed or timed out. Used to defer the
|
|
582
|
+
# outer paint so it lands on a fully-formed inner frame.
|
|
583
|
+
def any_pane_syncing?
|
|
584
|
+
return true if @session.window.panes.any? { |p| p.terminal.sync_pending? }
|
|
585
|
+
drawer = @session.drawer&.pane
|
|
586
|
+
return true if drawer && drawer.terminal.sync_pending?
|
|
587
|
+
false
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def nearest_sync_deadline
|
|
591
|
+
deadlines = @session.window.panes.filter_map { |p| p.terminal.sync_deadline }
|
|
592
|
+
d = @session.drawer&.pane&.terminal&.sync_deadline
|
|
593
|
+
deadlines << d if d
|
|
594
|
+
deadlines.min
|
|
595
|
+
end
|
|
596
|
+
|
|
519
597
|
def accept_client
|
|
520
598
|
sock = @listening_socket.accept
|
|
521
599
|
if @current_client
|
|
@@ -579,6 +657,13 @@ module Muxr
|
|
|
579
657
|
nil
|
|
580
658
|
end
|
|
581
659
|
|
|
660
|
+
def pane_for_writer_io(io)
|
|
661
|
+
pane = @session.window.panes.find { |p| p.writer_io == io }
|
|
662
|
+
return pane if pane
|
|
663
|
+
return @session.drawer.pane if @session.drawer&.pane && @session.drawer.pane.writer_io == io
|
|
664
|
+
nil
|
|
665
|
+
end
|
|
666
|
+
|
|
582
667
|
def prune_dead_panes
|
|
583
668
|
dead = @session.window.panes.reject(&:alive?)
|
|
584
669
|
return if dead.empty?
|
|
@@ -612,6 +697,11 @@ module Muxr
|
|
|
612
697
|
|
|
613
698
|
def disconnect_client(reason: nil)
|
|
614
699
|
return unless @current_client
|
|
700
|
+
# Best-effort: drop any queued OUTPUT (the client is going away),
|
|
701
|
+
# send a final BYE, then close. BYE is small enough that one
|
|
702
|
+
# blocking write won't meaningfully wedge anything even if the
|
|
703
|
+
# client's recv is sluggish.
|
|
704
|
+
@client_write_buffer = +"".b
|
|
615
705
|
safe_protocol_write(@current_client, Protocol::BYE, reason || "")
|
|
616
706
|
@current_client.close rescue nil
|
|
617
707
|
@current_client = nil
|
|
@@ -621,6 +711,7 @@ module Muxr
|
|
|
621
711
|
return unless @current_client
|
|
622
712
|
@current_client.close rescue nil
|
|
623
713
|
@current_client = nil
|
|
714
|
+
@client_write_buffer = +"".b
|
|
624
715
|
end
|
|
625
716
|
|
|
626
717
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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!
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -85,19 +85,32 @@ module Muxr
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def feed(data)
|
|
88
|
-
|
|
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..] || ""
|
|
89
108
|
case @state
|
|
90
109
|
when :help
|
|
91
110
|
@app.dismiss_help
|
|
92
111
|
@state = :idle
|
|
93
112
|
when :confirm_quit
|
|
94
113
|
handle_confirm_quit(ch)
|
|
95
|
-
when :idle
|
|
96
|
-
if ch == PREFIX
|
|
97
|
-
@state = :prefix
|
|
98
|
-
else
|
|
99
|
-
@app.send_to_focused(ch)
|
|
100
|
-
end
|
|
101
114
|
when :prefix
|
|
102
115
|
handle_prefix(ch)
|
|
103
116
|
when :command
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -20,15 +20,38 @@ 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
|
|
26
38
|
|
|
39
|
+
# Drain everything currently in the PTY's kernel read buffer, feeding
|
|
40
|
+
# each chunk to the Terminal. Coalescing reads here means we render once
|
|
41
|
+
# per fully-formed output burst (fzf re-render, vim cursor+status redraw,
|
|
42
|
+
# etc.) instead of once per ~8 KiB chunk — the latter shows intermediate
|
|
43
|
+
# frames and is the main source of in-pane flicker. Bounded by a byte cap
|
|
44
|
+
# so a runaway producer can't starve other panes on a single tick.
|
|
45
|
+
READ_BUDGET = 1 << 20 # 1 MiB
|
|
27
46
|
def read_from_pty
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
total = 0
|
|
48
|
+
while total < READ_BUDGET
|
|
49
|
+
chunk = @process.read_nonblock
|
|
50
|
+
break unless chunk
|
|
51
|
+
@terminal.feed(chunk)
|
|
52
|
+
total += chunk.bytesize
|
|
53
|
+
end
|
|
54
|
+
total.positive? ? total : nil
|
|
32
55
|
end
|
|
33
56
|
|
|
34
57
|
def resize(rows, cols)
|
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
|
-
|
|
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.
|
data/lib/muxr/pty_process.rb
CHANGED
|
@@ -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
|
-
@
|
|
28
|
-
|
|
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
|
@@ -371,7 +371,13 @@ module Muxr
|
|
|
371
371
|
end
|
|
372
372
|
|
|
373
373
|
def emit_frame(frame, session, input_state:, command_buffer:)
|
|
374
|
-
|
|
374
|
+
# \e[?2026h enters synchronized-output mode so terminals that support it
|
|
375
|
+
# (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
|
|
376
|
+
# the whole frame atomically instead of repainting incrementally as bytes
|
|
377
|
+
# arrive. \e[?25l hides the cursor for the duration of the diff so it
|
|
378
|
+
# doesn't smear across every \e[y;xH position; cursor_position turns it
|
|
379
|
+
# back on at the final spot.
|
|
380
|
+
out = String.new("\e[?2026h\e[?25l\e[0m")
|
|
375
381
|
same_size = @prev && @prev_w == frame[0].length && @prev_h == frame.length
|
|
376
382
|
cur_fg = :unset
|
|
377
383
|
cur_bg = :unset
|
|
@@ -400,6 +406,7 @@ module Muxr
|
|
|
400
406
|
end
|
|
401
407
|
out << "\e[0m"
|
|
402
408
|
out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
|
|
409
|
+
out << "\e[?2026l"
|
|
403
410
|
@out.write(out)
|
|
404
411
|
@out.flush
|
|
405
412
|
@prev = frame.map { |row| row.map(&:dup) }
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -12,6 +12,14 @@ module Muxr
|
|
|
12
12
|
|
|
13
13
|
SCROLLBACK_MAX = 5000
|
|
14
14
|
|
|
15
|
+
# Inner programs (fzf ≥ 0.41, neovim, helix, …) bracket coherent screen
|
|
16
|
+
# updates with `\e[?2026h … \e[?2026l` (DECSET 2026 — "Synchronized
|
|
17
|
+
# Output"). When we see the open, we know more bytes are coming that
|
|
18
|
+
# belong to the same logical frame; rendering before the close shows a
|
|
19
|
+
# half-painted state. SYNC_TIMEOUT is the safety cap so a crashed inner
|
|
20
|
+
# program (which left ?2026h open) cannot wedge the pane indefinitely.
|
|
21
|
+
SYNC_TIMEOUT = 0.2
|
|
22
|
+
|
|
15
23
|
Cell = Struct.new(:char, :fg, :bg, :attrs) do
|
|
16
24
|
def reset!
|
|
17
25
|
self.char = " "
|
|
@@ -52,6 +60,27 @@ module Muxr
|
|
|
52
60
|
@selection_anchor = nil
|
|
53
61
|
@selection_cursor = nil
|
|
54
62
|
@selection_mode = :linear
|
|
63
|
+
@sync_pending = false
|
|
64
|
+
@sync_started_at = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True iff the inner program has opened a synchronized-output block
|
|
68
|
+
# (\e[?2026h) and not yet closed it, and the safety timeout has not
|
|
69
|
+
# elapsed. The Application uses this to defer rendering so the diff lands
|
|
70
|
+
# on a fully-formed frame instead of a half-painted one.
|
|
71
|
+
def sync_pending?
|
|
72
|
+
return false unless @sync_pending
|
|
73
|
+
if @sync_started_at && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @sync_started_at) > SYNC_TIMEOUT
|
|
74
|
+
@sync_pending = false
|
|
75
|
+
@sync_started_at = nil
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sync_deadline
|
|
82
|
+
return nil unless @sync_pending && @sync_started_at
|
|
83
|
+
@sync_started_at + SYNC_TIMEOUT
|
|
55
84
|
end
|
|
56
85
|
|
|
57
86
|
attr_reader :selection_mode
|
|
@@ -527,8 +556,20 @@ module Muxr
|
|
|
527
556
|
when ">", "<", "=", "!"
|
|
528
557
|
return
|
|
529
558
|
when "?"
|
|
530
|
-
# DEC private modes — we treat
|
|
531
|
-
#
|
|
559
|
+
# DEC private modes — most we treat as no-ops, but mode 2026
|
|
560
|
+
# (Synchronized Output) is a render-timing hint we honor so the
|
|
561
|
+
# outer paint lands on fully-formed frames from fzf/nvim/helix.
|
|
562
|
+
if final == "h" || final == "l"
|
|
563
|
+
if csi_params.include?(2026)
|
|
564
|
+
if final == "h"
|
|
565
|
+
@sync_pending = true
|
|
566
|
+
@sync_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
567
|
+
else
|
|
568
|
+
@sync_pending = false
|
|
569
|
+
@sync_started_at = nil
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
532
573
|
return
|
|
533
574
|
end
|
|
534
575
|
|
data/lib/muxr/version.rb
CHANGED