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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab53a383e7781f83ff106e503047cf5952b12193d0d65e2b29631f2beda6bcf5
4
- data.tar.gz: 0fcef17549ae1745d76fe530a97f12740c9556704bbe724f1b4690932c7cabc5
3
+ metadata.gz: a22c29848cc6a0454f9be515cbc0f654537e37f9fbde37902f55dd34c103ce21
4
+ data.tar.gz: b96e180cc3750f1f0d3f72fc822669acaf68cc33c65b312029664b36981794cb
5
5
  SHA512:
6
- metadata.gz: 4440cb89e4295407dce42ca26c474c64441ff556cb285769b085e0474448132085d9bb6ad9af91699e6349a915c90fe6ebd33825d7ee68f315cafc26ccac5244
7
- data.tar.gz: e17c2f092dcd76ca617bfbfd6acdf1e5475ea9fd7afe0a7458539af95eaa32b0282d499a094f19a6887b6fd228f03d470d5a8c6b9b167b9838e6989edd20ee74
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.2...HEAD
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
@@ -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; ships one OUTPUT frame to the
405
- # currently attached client. No-op when nobody is attached.
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
- sock = @current_client
408
- return unless sock
409
- Protocol.write(sock, Protocol::OUTPUT, bytes)
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
- ready_ios = [@listening_socket]
483
- ready_ios << @current_client if @current_client
484
- @session.window.panes.each { |p| ready_ios << p.io if p.alive? }
485
- if @session.drawer&.pane && @session.drawer.pane.alive?
486
- ready_ios << @session.drawer.pane.io
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
- ready, = IO.select(ready_ios, nil, nil, timeout)
491
-
492
- if ready
493
- ready.each do |io|
494
- if io == @listening_socket
495
- accept_client
496
- elsif io == @current_client
497
- consume_client_frame
498
- else
499
- consume_pane_io(io)
500
- end
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
- render
514
- @needs_render = false
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
- 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!
@@ -85,19 +85,32 @@ module Muxr
85
85
  end
86
86
 
87
87
  def feed(data)
88
- 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..] || ""
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
- data = @process.read_nonblock
29
- return nil unless data
30
- @terminal.feed(data)
31
- data
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
- 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
@@ -371,7 +371,13 @@ module Muxr
371
371
  end
372
372
 
373
373
  def emit_frame(frame, session, input_state:, command_buffer:)
374
- out = String.new("\e[0m")
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 `h`/`l` as no-ops anyway, so dropping
531
- # everything is safe and avoids `\e[?Nr` colliding with DECSTBM.
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
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.2"
2
+ VERSION = "0.1.4"
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.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc