muxr 0.1.10 → 0.2.0

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.
@@ -1,5 +1,11 @@
1
1
  require "socket"
2
2
  require "fileutils"
3
+ require "securerandom"
4
+ require "muxr/remote_pane"
5
+ require "muxr/pane_transfer"
6
+ require "muxr/pane_picker"
7
+ require "muxr/session_directory"
8
+ require "muxr/config"
3
9
 
4
10
  module Muxr
5
11
  # The Application is the muxr server. It owns the Session, panes, Renderer,
@@ -22,26 +28,40 @@ module Muxr
22
28
  # showing through.
23
29
  MIN_FRAME_INTERVAL = 1.0 / 60
24
30
  SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
31
+ CAPTURES_DIR = File.join(Dir.home, ".muxr", "captures").freeze
25
32
  DEFAULT_WIDTH = 80
26
33
  DEFAULT_HEIGHT = 24
27
34
 
28
- attr_reader :session, :renderer, :input, :session_name, :control_server
35
+ attr_reader :session, :renderer, :input, :session_name, :control_server, :pane_picker
29
36
 
30
37
  def self.socket_path_for(name)
31
38
  File.join(SOCKETS_DIR, "#{name}.sock")
32
39
  end
33
40
 
41
+ # The name a bare `muxr` (no name argument) resolves to: a slug of the
42
+ # current working directory, so re-running `muxr` in the same directory
43
+ # reattaches the same session. The name is interpolated straight into
44
+ # <name>.sock / <name>.json / <name>.log filenames, so the path's slashes
45
+ # (and any other filesystem-unfriendly bytes) are folded to "-"; the
46
+ # leading "-" from the root slash is trimmed for readability in --list.
47
+ def self.default_session_name(dir = Dir.pwd)
48
+ slug = dir.gsub(%r{/}, "-").gsub(/[^A-Za-z0-9._-]/, "-").sub(/\A-+/, "")
49
+ slug.empty? ? "default" : slug
50
+ end
51
+
34
52
  def self.control_socket_path_for(name)
35
53
  File.join(SOCKETS_DIR, "#{name}.ctrl.sock")
36
54
  end
37
55
 
38
56
  # Names of sessions whose server socket is currently accepting connections.
39
57
  # Stale sockets (file exists, no listener) are skipped but left in place;
40
- # cleanup happens on the next attach attempt.
58
+ # cleanup happens on the next attach attempt. The sibling control socket
59
+ # lives in the same directory under <name>.ctrl.sock and is not a session.
41
60
  def self.list_active
42
61
  return [] unless File.directory?(SOCKETS_DIR)
43
62
  Dir.children(SOCKETS_DIR).filter_map do |entry|
44
63
  next unless entry.end_with?(".sock")
64
+ next if entry.end_with?(".ctrl.sock")
45
65
  path = File.join(SOCKETS_DIR, entry)
46
66
  next unless alive_socket?(path)
47
67
  File.basename(entry, ".sock")
@@ -64,15 +84,40 @@ module Muxr
64
84
  @message = nil
65
85
  @message_expires = nil
66
86
  @help_visible = false
87
+ @pane_picker = nil
67
88
  @current_client = nil
68
89
  @client_write_buffer = +"".b
69
90
  @listening_socket = nil
91
+ # The directory bin/muxr was launched from — Process.daemon(true, ...)
92
+ # preserves it across daemonization. Every new pane (and the drawer)
93
+ # starts here, treating it as the session's project root regardless of
94
+ # where the focused pane's shell has wandered.
95
+ @origin_cwd = Dir.pwd
70
96
  @socket_path = self.class.socket_path_for(@session_name)
71
97
  @control_socket_path = self.class.control_socket_path_for(@session_name)
72
98
  @control_server = nil
73
99
  @paste_buffer = +""
100
+ # Trailing bytes of an in-flight INPUT chunk that look like the start of
101
+ # a bracketed-paste marker but were cut off by the 4 KiB read boundary.
102
+ # Held back and prepended to the next chunk so a split marker still gets
103
+ # recognized — see #strip_bracketed_paste_markers.
104
+ @paste_marker_tail = +"".b
74
105
  @last_render_at = nil
75
106
  @foreground_poller = nil
107
+ # Opt-in diagnostic tap. When MUXR_TRACE_OUTPUT names a writable path, the
108
+ # server appends every byte it sends to the client — i.e. exactly what the
109
+ # outer terminal receives. Replaying it (`cat` it into a fresh terminal, or
110
+ # feed it to a reference emulator) reproduces a rendering bug from the byte
111
+ # stream alone, which tells us whether corruption is in muxr's emitted
112
+ # output or somewhere downstream. Off unless the env var is set.
113
+ @trace_output = open_trace(ENV["MUXR_TRACE_OUTPUT"])
114
+ end
115
+
116
+ def open_trace(path)
117
+ return nil if path.nil? || path.empty?
118
+ File.open(path, "ab")
119
+ rescue SystemCallError
120
+ nil
76
121
  end
77
122
 
78
123
  # Interval for the background thread that refreshes each pane's
@@ -94,13 +139,117 @@ module Muxr
94
139
 
95
140
  # ---------- public action API (called from InputHandler / CommandDispatcher) ----------
96
141
 
142
+ # Bytes the outer terminal wraps around a paste once bracketed-paste mode
143
+ # is on (the client enables it unconditionally — see
144
+ # Client#enter_terminal_mode).
145
+ BRACKETED_PASTE_MARKERS = ["\e[200~".b, "\e[201~".b].freeze
146
+
97
147
  def send_to_focused(data)
98
148
  target = focused_target
99
- target&.write(data)
149
+ return unless target
150
+ data = strip_bracketed_paste_markers(data, target)
151
+ input_targets.each { |pane| pane.write(data) } unless data.empty?
152
+ end
153
+
154
+ def input_targets
155
+ target = focused_target
156
+ return [] unless target
157
+ return [target] unless broadcasting?
158
+ @session.window.panes.select { |pane| pane.alive? && !handing_off?(pane) }
159
+ end
160
+
161
+ def broadcasting?
162
+ @session.window.synchronized && !(@session.focus_drawer && @session.drawer&.visible?)
163
+ end
164
+
165
+ def capture_focused(path = nil)
166
+ target = focused_target
167
+ return unless target
168
+ destination = capture_path(target, path)
169
+ text = target.terminal.dump_history_text
170
+ FileUtils.mkdir_p(File.dirname(destination))
171
+ File.write(destination, text)
172
+ flash("captured #{text.count("\n")} lines to #{shorten_home(destination)}")
173
+ destination
174
+ rescue SystemCallError => e
175
+ flash("capture failed: #{e.message}")
176
+ nil
177
+ end
178
+
179
+ def capture_path(target, path)
180
+ return File.expand_path(path, @origin_cwd) if path && !path.empty?
181
+ label = target.respond_to?(:label) ? target.label : target.id.to_s
182
+ stamp = Time.now.strftime("%Y%m%d-%H%M%S")
183
+ File.join(CAPTURES_DIR, "#{@session_name}-#{label}-#{stamp}.txt".gsub(/[^A-Za-z0-9._-]/, "-"))
184
+ end
185
+
186
+ def rename_focused(name)
187
+ pane = focused_pane
188
+ return unless pane
189
+ pane.name = name
190
+ flash(pane.name ? "pane ##{@session.window.focused_index + 1} is now #{pane.name}" : "pane ##{@session.window.focused_index + 1} name cleared")
191
+ invalidate
192
+ end
193
+
194
+ def set_sync(arg)
195
+ win = @session.window
196
+ case arg
197
+ when nil then win.synchronized = !win.synchronized
198
+ when "on" then win.synchronized = true
199
+ when "off" then win.synchronized = false
200
+ else return flash("sync: expected on or off")
201
+ end
202
+ flash(win.synchronized ? "sync on: typing reaches all #{win.panes.length} panes" : "sync off")
203
+ invalidate
204
+ end
205
+
206
+ # The client turns bracketed-paste mode on for the *outer* terminal so big
207
+ # pastes arrive wrapped in \e[200~…\e[201~ (which lets shells/editors that
208
+ # speak the protocol collapse them). But the focused program may not speak
209
+ # it — in that case the markers would print as a literal "^[[200~" before
210
+ # and after the text. So: forward the markers untouched when the focused
211
+ # program enabled DECSET 2004, strip them otherwise.
212
+ #
213
+ # A marker can straddle a 4 KiB read boundary, so any trailing bytes that
214
+ # form a partial marker (but not a bare ESC, which must reach the program
215
+ # immediately as the Escape key) are held back and prepended next chunk.
216
+ def strip_bracketed_paste_markers(data, target)
217
+ data = data.b
218
+ term = target.respond_to?(:terminal) ? target.terminal : nil
219
+ buf = @paste_marker_tail + data
220
+ @paste_marker_tail = +"".b
221
+
222
+ if term&.bracketed_paste?
223
+ # Program wants the markers — hand back everything, partial included.
224
+ return buf
225
+ end
226
+
227
+ hold = pending_marker_prefix(buf)
228
+ if hold.positive?
229
+ @paste_marker_tail = buf.byteslice(buf.bytesize - hold, hold)
230
+ buf = buf.byteslice(0, buf.bytesize - hold) || +"".b
231
+ end
232
+ BRACKETED_PASTE_MARKERS.each { |m| buf = buf.gsub(m, "") }
233
+ buf
234
+ end
235
+
236
+ # Length (2..5) of the longest suffix of `buf` that is a proper prefix of a
237
+ # bracketed-paste marker, so the remainder can arrive in the next chunk. A
238
+ # bare trailing ESC (length 1) is deliberately not held: it's almost always
239
+ # the Escape key and the program must see it without waiting on the next
240
+ # keystroke. Worst case a marker split right after its ESC leaks a few
241
+ # bytes, which the program reads as a harmless unknown escape.
242
+ def pending_marker_prefix(buf)
243
+ max = [buf.bytesize, 5].min
244
+ max.downto(2) do |k|
245
+ tail = buf.byteslice(buf.bytesize - k, k)
246
+ return k if BRACKETED_PASTE_MARKERS.any? { |m| m.byteslice(0, k) == tail }
247
+ end
248
+ 0
100
249
  end
101
250
 
102
251
  def new_pane(cwd: nil)
103
- cwd ||= focused_pane&.cwd
252
+ cwd ||= @origin_cwd
104
253
  pane = make_pane(cwd: cwd)
105
254
  @session.window.add_pane(pane)
106
255
  @session.focus_drawer = false
@@ -116,6 +265,7 @@ module Muxr
116
265
  else
117
266
  @session.window.focus_next
118
267
  end
268
+ sync_input_mode_to_focus
119
269
  invalidate
120
270
  end
121
271
 
@@ -126,6 +276,7 @@ module Muxr
126
276
  else
127
277
  @session.window.focus_prev
128
278
  end
279
+ sync_input_mode_to_focus
129
280
  invalidate
130
281
  end
131
282
 
@@ -136,6 +287,7 @@ module Muxr
136
287
  else
137
288
  @session.window.focus_last
138
289
  end
290
+ sync_input_mode_to_focus
139
291
  invalidate
140
292
  end
141
293
 
@@ -145,9 +297,27 @@ module Muxr
145
297
  return unless idx >= 0 && idx < @session.window.panes.length
146
298
  @session.focus_drawer = false
147
299
  @session.window.focus_index(idx)
300
+ sync_input_mode_to_focus
148
301
  invalidate
149
302
  end
150
303
 
304
+ # After a focus change, reconcile the input mode with the newly-focused
305
+ # pane: if it was left scrolled back, re-enter scrollback so the user
306
+ # lands exactly where they were reading ("navigating back to the scrolled
307
+ # pane puts you back into scrollback"). We only ever auto-ENTER here —
308
+ # the InputHandler's @prefix_return is what keeps you in scrollback when
309
+ # you hop onto a live pane, so we never auto-leave.
310
+ def sync_input_mode_to_focus
311
+ target = focused_target
312
+ return unless target
313
+ if target.terminal.scrolled_back?
314
+ @input.enter_scrollback_mode(source: :ring)
315
+ @renderer.reset_frame!
316
+ elsif @input.state == :scrollback
317
+ @input.enter_scrollback_mode(source: default_scroll_source(target))
318
+ end
319
+ end
320
+
151
321
  # Move focus to the pane spatially adjacent in `direction` (:left/:right/
152
322
  # :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
153
323
  # layout rects keeps this in sync with whatever the renderer is showing.
@@ -168,12 +338,14 @@ module Muxr
168
338
  when :right, :down then win.focus_next
169
339
  when :left, :up then win.focus_prev
170
340
  end
341
+ sync_input_mode_to_focus
171
342
  invalidate
172
343
  return
173
344
  end
174
345
 
175
346
  return unless idx
176
347
  win.focus_index(idx)
348
+ sync_input_mode_to_focus
177
349
  invalidate
178
350
  end
179
351
 
@@ -222,7 +394,7 @@ module Muxr
222
394
  # Ctrl-a-prefixed multiplexer mode.
223
395
  def enter_passthrough_mode
224
396
  @input.enter_passthrough_mode
225
- flash("passthrough mode (^a esc to return)")
397
+ flash("passthrough mode (^#{Renderer.prefix_letter(@input.prefix)} esc to return)")
226
398
  invalidate
227
399
  end
228
400
 
@@ -269,17 +441,86 @@ module Muxr
269
441
  invalidate
270
442
  end
271
443
 
444
+ def toggle_zoom
445
+ win = @session.window
446
+ return flash("already in monocle") if win.layout == :monocle && !win.zoomed?
447
+ win.toggle_zoom
448
+ flash(win.zoomed? ? "zoomed (z to restore #{win.zoom_return})" : "layout: #{win.layout}")
449
+ @renderer.reset_frame!
450
+ invalidate
451
+ end
452
+
272
453
  def cycle_layout
273
454
  @session.window.cycle_layout
274
455
  flash("layout: #{@session.window.layout}")
275
456
  invalidate
276
457
  end
277
458
 
459
+ # Bound to `r` (normal) / `Ctrl-a r` (passthrough). Two-layer repaint to
460
+ # recover from a corrupted display, whichever layer drifted:
461
+ # 1. Nudge the focused program to redraw itself (SIGWINCH wiggle). This
462
+ # fixes muxr's own Terminal grid when an unhandled or wide glyph
463
+ # desynced the cursor — reset_frame! alone can't, since it would just
464
+ # faithfully re-emit the wrong grid.
465
+ # 2. Force a full re-emit of our composed frame to the outer terminal,
466
+ # fixing the case where the outer display lost/garbled bytes but our
467
+ # grid is correct.
468
+ def refresh_focused
469
+ target = focused_target
470
+ target.request_redraw if target.respond_to?(:request_redraw)
471
+ @renderer.reset_frame!
472
+ flash("refreshed")
473
+ invalidate
474
+ end
475
+
278
476
  def promote_master
279
477
  @session.window.promote_to_master
280
478
  invalidate
281
479
  end
282
480
 
481
+ def grow_master
482
+ resize_master(Window::RATIO_STEP)
483
+ end
484
+
485
+ def shrink_master
486
+ resize_master(-Window::RATIO_STEP)
487
+ end
488
+
489
+ def resize_master(delta)
490
+ @session.window.adjust_master_ratio(delta)
491
+ flash_master_shape
492
+ end
493
+
494
+ def add_master
495
+ @session.window.adjust_master_count(1)
496
+ flash_master_shape
497
+ end
498
+
499
+ def remove_master
500
+ @session.window.adjust_master_count(-1)
501
+ flash_master_shape
502
+ end
503
+
504
+ def set_master_ratio(arg)
505
+ value = Float(arg.to_s.delete_suffix("%"), exception: false)
506
+ return flash("ratio: expected a percentage like 60") unless value&.positive?
507
+ @session.window.master_ratio = value > 1 ? value / 100.0 : value
508
+ flash_master_shape
509
+ end
510
+
511
+ def set_master_count(arg)
512
+ value = Integer(arg.to_s, exception: false)
513
+ return flash("masters: expected a number like 2") unless value&.positive?
514
+ @session.window.master_count = value
515
+ flash_master_shape
516
+ end
517
+
518
+ def flash_master_shape
519
+ win = @session.window
520
+ flash("master #{(win.master_ratio * 100).round}% · masters #{win.master_count}")
521
+ invalidate
522
+ end
523
+
283
524
  # Toggle the privacy flag on the focused pane. Private panes are
284
525
  # redacted from the MCP control surface (panes.list strips cwd; read /
285
526
  # send_input / run / subscribe / kill all refuse). Only the human can
@@ -371,6 +612,103 @@ module Muxr
371
612
  invalidate
372
613
  end
373
614
 
615
+ # Open the attach overlay: every pane every other live muxr server is
616
+ # willing to share, grouped by session. Building the list means a short
617
+ # blocking round-trip to each server's control socket, which is fine for a
618
+ # deliberate keypress and bounded by SessionDirectory::QUERY_TIMEOUT.
619
+ def open_pane_picker
620
+ entries = SessionDirectory.panes(exclude: @session_name)
621
+ if entries.empty?
622
+ flash("no panes to attach (no other muxr sessions running)")
623
+ return
624
+ end
625
+ @pane_picker = PanePicker.new(entries)
626
+ @input.enter_pane_picker_mode
627
+ invalidate
628
+ end
629
+
630
+ def move_pane_picker(delta)
631
+ @pane_picker&.move(delta)
632
+ invalidate
633
+ end
634
+
635
+ def cancel_pane_picker
636
+ @pane_picker = nil
637
+ @renderer.reset_frame!
638
+ invalidate
639
+ end
640
+
641
+ def confirm_pane_picker(move: false)
642
+ entry = @pane_picker&.selected
643
+ cancel_pane_picker
644
+ return unless entry
645
+ move ? move_remote_pane(entry) : attach_remote_pane(entry)
646
+ end
647
+
648
+ # Take the pane away from its session rather than sharing it. The pty fd
649
+ # itself crosses over, so the shell and everything running under it carry
650
+ # on uninterrupted — it just answers to this session now, and disappears
651
+ # from the one it came from.
652
+ def move_remote_pane(entry)
653
+ result = PaneTransfer.claim(socket_path: entry.socket_path, pane_id: entry.pane_id)
654
+ pane = result.pane
655
+ pane.foreground_command = nil
656
+ @session.window.add_pane(pane)
657
+ @session.focus_drawer = false
658
+ @session.window.focused_index = @session.window.panes.length - 1
659
+ @renderer.reset_frame!
660
+ flash("moved #{result.session}:#{pane.id} here")
661
+ invalidate
662
+ pane
663
+ rescue PaneTransfer::Error => e
664
+ flash("move failed: #{e.message}")
665
+ nil
666
+ end
667
+
668
+ # Mount another session's pane here as a live mirror. The pane keeps
669
+ # running where it is — both sessions see the same shell, and either can
670
+ # type into it. Closing it here (or quitting) only drops the mirror; losing
671
+ # the owning server is what makes the pane go away, and prune_dead_panes
672
+ # takes care of that.
673
+ def attach_remote_pane(entry)
674
+ remote = RemotePane.connect(
675
+ socket_path: entry.socket_path,
676
+ pane_id: entry.pane_id,
677
+ rows: mirror_viewport[0],
678
+ cols: mirror_viewport[1]
679
+ )
680
+ pane = Pane.new(rows: remote.rows, cols: remote.cols, process: remote)
681
+ remote.bind(pane.terminal)
682
+ pane.origin = remote.origin
683
+ pane.name = entry.name if entry.respond_to?(:name)
684
+ @session.window.add_pane(pane)
685
+ @session.focus_drawer = false
686
+ @session.window.focused_index = @session.window.panes.length - 1
687
+ @renderer.reset_frame!
688
+ flash("attached #{remote.origin}")
689
+ invalidate
690
+ pane
691
+ rescue RemotePane::Error => e
692
+ flash("attach failed: #{e.message}")
693
+ nil
694
+ end
695
+
696
+ # Size to ask the owner for before the Renderer has laid the new pane out.
697
+ # One more pane in the current layout is the honest guess, and the very next
698
+ # frame corrects it through Pane#resize.
699
+ def mirror_viewport
700
+ rects = LayoutManager.compute(
701
+ @session.window.layout,
702
+ @session.window.panes.length + 1,
703
+ LayoutManager::Rect.new(0, 0, @session.width, @session.height - 1),
704
+ focused_index: @session.window.panes.length,
705
+ **@session.window.layout_options
706
+ )
707
+ rect = rects.last
708
+ return [DEFAULT_HEIGHT, DEFAULT_WIDTH] unless rect
709
+ [[rect.h - 2, 1].max, [rect.w - 2, 1].max]
710
+ end
711
+
374
712
  def show_help
375
713
  @help_visible = true
376
714
  @input.enter_help_mode
@@ -385,13 +723,47 @@ module Muxr
385
723
  def enter_scrollback
386
724
  target = focused_target
387
725
  return unless target
388
- @input.enter_scrollback_mode
726
+ @input.enter_scrollback_mode(source: default_scroll_source(target))
727
+ @renderer.reset_frame!
728
+ invalidate
729
+ end
730
+
731
+ def app_scroll_available?(target)
732
+ term = target&.terminal
733
+ return false unless term
734
+ term.mouse_tracking? || term.alt_screen?
735
+ end
736
+
737
+ def default_scroll_source(target)
738
+ app_scroll_available?(target) ? :app : :ring
739
+ end
740
+
741
+ def toggle_scroll_source
742
+ target = focused_target
743
+ return unless target
744
+ if @input.scroll_source == :app
745
+ if target.terminal.alt_screen?
746
+ flash("no history while a full-screen app is running")
747
+ return
748
+ end
749
+ @input.enter_scrollback_mode(source: :ring)
750
+ flash("scrolling muxr history")
751
+ else
752
+ unless app_scroll_available?(target)
753
+ flash("this pane has no scroll of its own")
754
+ return
755
+ end
756
+ target.terminal.scroll_to_bottom
757
+ @input.enter_scrollback_mode(source: :app)
758
+ flash("scrolling the app")
759
+ end
389
760
  @renderer.reset_frame!
390
761
  invalidate
391
762
  end
392
763
 
393
764
  def exit_scrollback
394
765
  target = focused_target
766
+ target&.terminal&.confine_selection_to_screen!(false)
395
767
  target&.terminal&.clear_selection
396
768
  target&.terminal&.clear_search
397
769
  target&.terminal&.scroll_to_bottom
@@ -403,6 +775,10 @@ module Muxr
403
775
  # the user into a buffered prompt; commit_search / cancel_search exit
404
776
  # back to scrollback.
405
777
  def enter_search(direction: :forward)
778
+ if @input.scroll_source == :app
779
+ flash("search reads muxr history — Tab to switch")
780
+ return
781
+ end
406
782
  @input.enter_search_mode(direction: direction)
407
783
  invalidate
408
784
  end
@@ -441,6 +817,10 @@ module Muxr
441
817
  def step_search(direction)
442
818
  target = focused_target
443
819
  return unless target
820
+ if @input.scroll_source == :app
821
+ flash("search reads muxr history — Tab to switch")
822
+ return
823
+ end
444
824
  term = target.terminal
445
825
  if term.search_matches.empty?
446
826
  flash("no search active")
@@ -450,9 +830,12 @@ module Muxr
450
830
  invalidate
451
831
  end
452
832
 
833
+ WHEEL_BURST_MAX = 200
834
+
453
835
  def scroll_focused(action)
454
836
  target = focused_target
455
837
  return unless target
838
+ return scroll_app(target, action) if @input.scroll_source == :app
456
839
  term = target.terminal
457
840
  rows = term.rows
458
841
  case action
@@ -468,6 +851,48 @@ module Muxr
468
851
  invalidate
469
852
  end
470
853
 
854
+ def scroll_app(target, action)
855
+ term = target.terminal
856
+ rows = term.rows
857
+ direction, count =
858
+ case action
859
+ when :line_back then [:up, 1]
860
+ when :line_forward then [:down, 1]
861
+ when :half_back then [:up, [rows / 2, 1].max]
862
+ when :half_forward then [:down, [rows / 2, 1].max]
863
+ when :full_back then [:up, [rows - 1, 1].max]
864
+ when :full_forward then [:down, [rows - 1, 1].max]
865
+ end
866
+ unless direction
867
+ flash("only the app knows where its history starts")
868
+ return
869
+ end
870
+ target.write(app_scroll_bytes(term, direction, count))
871
+ invalidate
872
+ end
873
+
874
+ def app_scroll_bytes(term, direction, count)
875
+ count = count.clamp(1, WHEEL_BURST_MAX)
876
+ if term.mouse_tracking?
877
+ MouseReport.wheel(
878
+ direction,
879
+ row: [term.rows / 2 + 1, 1].max,
880
+ col: [term.cols / 2 + 1, 1].max,
881
+ encoding: term.mouse_encoding
882
+ ) * count
883
+ else
884
+ arrow_key(term, direction) * count
885
+ end
886
+ end
887
+
888
+ def arrow_key(term, direction)
889
+ if term.app_cursor_keys?
890
+ direction == :up ? "\eOA".b : "\eOB".b
891
+ else
892
+ direction == :up ? "\e[A".b : "\e[B".b
893
+ end
894
+ end
895
+
471
896
  def enter_selection
472
897
  target = focused_target
473
898
  return unless target
@@ -476,6 +901,7 @@ module Muxr
476
901
  # anchor. Start at the live cursor's visible position so the user lands
477
902
  # where their attention already is, instead of the top-left corner.
478
903
  term = target.terminal
904
+ term.confine_selection_to_screen!(@input.scroll_source == :app)
479
905
  term.place_selection_cursor(term.cursor_row, term.cursor_col)
480
906
  @input.enter_selection_mode
481
907
  @renderer.reset_frame!
@@ -502,7 +928,6 @@ module Muxr
502
928
  def exit_selection(yank:)
503
929
  target = focused_target
504
930
  term = target&.terminal
505
- yanked = false
506
931
  if yank
507
932
  # No anchor → no-op. User is still positioning; they can press v
508
933
  # first, then yank. Esc/q is the way to exit from navigation.
@@ -512,18 +937,14 @@ module Muxr
512
937
  @paste_buffer = text
513
938
  spawn_pbcopy(text)
514
939
  flash("yanked #{text.bytesize} bytes")
515
- yanked = true
516
940
  end
517
941
  end
518
942
  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
943
+ # Drop back into scrollback at the current position whether or not we
944
+ # yanked. We no longer snap to the live bottom on yank — the user stays
945
+ # where they were reading so they can keep selecting or scrolling, and
946
+ # `q`/Esc is still there when they want to return to the bottom.
947
+ @input.enter_scrollback_mode
527
948
  @renderer.reset_frame!
528
949
  invalidate
529
950
  end
@@ -561,10 +982,33 @@ module Muxr
561
982
  invalidate
562
983
  end
563
984
 
985
+ SILENCE_ARG = /\A(\d+)(s|m)?\z/
986
+
987
+ def monitor_silence(arg)
988
+ pane = focused_pane
989
+ return unless pane
990
+ if arg.nil?
991
+ flash(pane.silence_after ? "silence: #{format_seconds(pane.silence_after)}" : "silence: off")
992
+ elsif arg == "off"
993
+ pane.watch_silence(nil)
994
+ flash("silence monitor off")
995
+ elsif (m = SILENCE_ARG.match(arg)) && m[1].to_i.positive?
996
+ seconds = m[1].to_i * (m[2] == "m" ? 60 : 1)
997
+ pane.watch_silence(seconds)
998
+ flash("alert when pane ##{@session.window.focused_index + 1} is silent for #{format_seconds(seconds)}")
999
+ else
1000
+ flash("silence: expected seconds (30, 30s, 2m) or off")
1001
+ end
1002
+ invalidate
1003
+ end
1004
+
1005
+ def format_seconds(seconds)
1006
+ seconds % 60 == 0 && seconds >= 60 ? "#{seconds / 60}m" : "#{seconds}s"
1007
+ end
1008
+
564
1009
  def paste_from_buffer
565
1010
  return if @paste_buffer.nil? || @paste_buffer.empty?
566
- target = focused_target
567
- target&.write(@paste_buffer)
1011
+ input_targets.each { |pane| pane.write(@paste_buffer) }
568
1012
  end
569
1013
 
570
1014
  def flash(msg)
@@ -577,6 +1021,43 @@ module Muxr
577
1021
  @needs_render = true
578
1022
  end
579
1023
 
1024
+ def reload_config
1025
+ config = Config.load
1026
+ apply_config(config)
1027
+ if config.errors.empty?
1028
+ flash("reloaded #{shorten_home(config.path)}")
1029
+ else
1030
+ report_config_problems
1031
+ end
1032
+ end
1033
+
1034
+ def apply_config(config)
1035
+ @config = config
1036
+ config.errors.each { |e| warn("muxr: #{config.path}: #{e}") }
1037
+ @input.configure(config)
1038
+ Terminal.scrollback_max = config.scrollback if config.scrollback && !ENV["MUXR_SCROLLBACK"]
1039
+ LayoutManager.auto_spiral_min_cols = config.auto_spiral_min_cols
1040
+ LayoutManager.auto_spiral_min_rows = config.auto_spiral_min_rows
1041
+ invalidate
1042
+ end
1043
+
1044
+ def apply_window_defaults
1045
+ win = @session.window
1046
+ win.set_layout(@config.layout) if @config.layout
1047
+ win.master_ratio = @config.master_ratio if @config.master_ratio
1048
+ win.master_count = @config.master_count if @config.master_count
1049
+ end
1050
+
1051
+ def report_config_problems
1052
+ return if @config.nil? || @config.errors.empty?
1053
+ count = @config.errors.length
1054
+ flash("config: #{@config.errors.first}#{count > 1 ? " (+#{count - 1} more in the log)" : ""}")
1055
+ end
1056
+
1057
+ def shorten_home(path)
1058
+ path.to_s.start_with?(Dir.home) ? path.to_s.sub(Dir.home, "~") : path.to_s
1059
+ end
1060
+
580
1061
  def save_session
581
1062
  path = @session.save
582
1063
  flash("saved: #{path}")
@@ -592,7 +1073,7 @@ module Muxr
592
1073
  end
593
1074
 
594
1075
  def list_sessions
595
- names = Session.list
1076
+ names = (Session.list | self.class.list_active).sort
596
1077
  if names.empty?
597
1078
  flash("no saved sessions")
598
1079
  else
@@ -610,6 +1091,9 @@ module Muxr
610
1091
  # the server is also trying to read from that same client.
611
1092
  def deliver_output(bytes)
612
1093
  return unless @current_client
1094
+ if @trace_output
1095
+ @trace_output.write(bytes) rescue nil
1096
+ end
613
1097
  @client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
614
1098
  drain_client_writes
615
1099
  end
@@ -645,7 +1129,7 @@ module Muxr
645
1129
  if idx && argv[idx + 1]
646
1130
  argv[idx + 1]
647
1131
  else
648
- argv.find { |a| !a.start_with?("-") } || "default"
1132
+ argv.find { |a| !a.start_with?("-") } || self.class.default_session_name
649
1133
  end
650
1134
  end
651
1135
 
@@ -667,7 +1151,7 @@ module Muxr
667
1151
  win.panes.length,
668
1152
  area,
669
1153
  focused_index: win.focused_index,
670
- master_index: win.master_index
1154
+ **win.layout_options
671
1155
  )
672
1156
  end
673
1157
 
@@ -694,6 +1178,8 @@ module Muxr
694
1178
  @session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
695
1179
  @renderer = Renderer.new(out: FramedOutput.new(self))
696
1180
  @input = InputHandler.new(self)
1181
+ apply_config(Config.load)
1182
+ apply_window_defaults
697
1183
 
698
1184
  saved = Session.load(@session_name)
699
1185
  first_id = saved && saved.dig("panes", 0, "id")
@@ -718,20 +1204,24 @@ module Muxr
718
1204
  end
719
1205
  @session&.window&.panes&.each(&:close)
720
1206
  @session&.drawer&.close
1207
+ if @trace_output
1208
+ @trace_output.close rescue nil
1209
+ @trace_output = nil
1210
+ end
721
1211
  end
722
1212
 
723
1213
  def loop_forever
724
1214
  while @running
725
1215
  read_ios = [@listening_socket]
726
1216
  read_ios << @current_client if @current_client
727
- @session.window.panes.each { |p| read_ios << p.io if p.alive? }
1217
+ @session.window.panes.each { |p| read_ios << p.io if p.alive? && !handing_off?(p) }
728
1218
  drawer_pane = @session.drawer&.pane
729
1219
  read_ios << drawer_pane.io if drawer_pane&.alive?
730
1220
  read_ios.concat(@control_server.read_ios) if @control_server
731
1221
 
732
1222
  write_ios = []
733
1223
  @session.window.panes.each do |p|
734
- write_ios << p.writer_io if p.alive? && p.pending_write?
1224
+ write_ios << p.writer_io if p.alive? && p.pending_write? && !handing_off?(p)
735
1225
  end
736
1226
  if drawer_pane&.alive? && drawer_pane.pending_write?
737
1227
  write_ios << drawer_pane.writer_io
@@ -783,6 +1273,7 @@ module Muxr
783
1273
 
784
1274
  prune_dead_panes
785
1275
  prune_dead_drawer
1276
+ report_silent_panes
786
1277
  expire_message
787
1278
 
788
1279
  if @session.window.panes.empty?
@@ -840,12 +1331,30 @@ module Muxr
840
1331
 
841
1332
  size = Protocol.decode_size(payload)
842
1333
  apply_size(*size) if size
1334
+ apply_caps(Protocol.decode_caps(payload))
843
1335
 
844
1336
  @current_client = sock
845
1337
  @renderer.reset_frame!
1338
+ report_config_problems
846
1339
  invalidate
847
1340
  end
848
1341
 
1342
+ # Apply the client's width-probe verdict so the emulator and Renderer measure
1343
+ # glyphs exactly as this terminal draws them, eliminating the width
1344
+ # disagreement that smears in-place animations:
1345
+ # ambiguous (1 narrow / 2 wide) — tunes the broad East Asian Ambiguous
1346
+ # class for the long tail of glyphs the probe didn't sample by hand.
1347
+ # glyphs ({codepoint => width}) — exact per-glyph overrides for the
1348
+ # emoji-presentation glyphs (Claude Code's ⏺/✻/❯) no class predicts.
1349
+ # A reattaching client re-probes, so a different terminal re-tunes; the full
1350
+ # repaint on attach absorbs the change.
1351
+ def apply_caps(caps)
1352
+ return if caps.nil? || caps.empty?
1353
+ Terminal.ambiguous_wide = (caps[:ambiguous] == 2) if caps.key?(:ambiguous)
1354
+ Terminal.box_wide = (caps[:box] == 2) if caps.key?(:box)
1355
+ Terminal.width_overrides = caps[:glyphs] if caps.key?(:glyphs)
1356
+ end
1357
+
849
1358
  def consume_client_frame
850
1359
  type, payload = Protocol.read(@current_client)
851
1360
  if type.nil?
@@ -874,15 +1383,68 @@ module Muxr
874
1383
  def consume_pane_io(io)
875
1384
  pane = pane_for_io(io)
876
1385
  return unless pane
877
- data = pane.read_from_pty
1386
+ control = @control_server
1387
+ relay = pane.id.is_a?(String) && control&.mirrored?(pane.id)
1388
+ data =
1389
+ if relay
1390
+ pane.read_from_pty { |chunk| control.on_pane_raw(pane.id, chunk) }
1391
+ else
1392
+ pane.read_from_pty
1393
+ end
878
1394
  if data
879
1395
  invalidate
1396
+ pane.note_output(attended: attended?(pane))
880
1397
  # Notify the control surface so any pending pane.run waiters reset
881
1398
  # their idle window and any pane.subscribe clients get a new frame.
882
1399
  # read_from_pty already fed the bytes into the Terminal; the control
883
1400
  # server pulls the resulting text out of pane.terminal.dump_text.
884
1401
  @control_server&.on_pane_output(pane.id, data) if pane.id.is_a?(String)
885
1402
  end
1403
+ forward_notifications(pane)
1404
+ forward_clipboard(pane)
1405
+ end
1406
+
1407
+ # Push any bell / desktop-notification bytes the pane's emulator collected
1408
+ # straight to the outer terminal — out of band from the rendered frame, so a
1409
+ # background pane (an unfocused Claude Code finishing a task) still alerts
1410
+ # the user. When no client is attached deliver_output is a no-op; the queue
1411
+ # is still drained so it can't accumulate while detached.
1412
+ def forward_notifications(pane)
1413
+ bytes = pane.terminal.take_pending_notifications!
1414
+ return unless bytes
1415
+ pane.note_bell unless attended?(pane)
1416
+ deliver_output(bytes) if @current_client
1417
+ end
1418
+
1419
+ def report_silent_panes
1420
+ now = Pane.now
1421
+ @session.window.panes.each_with_index do |pane, i|
1422
+ next unless pane.silence_due?(now)
1423
+ pane.note_silence!
1424
+ flash("pane ##{i + 1} silent for #{format_seconds(pane.silence_after)}")
1425
+ deliver_output("\a".b) if @current_client
1426
+ end
1427
+ end
1428
+
1429
+ def attended?(pane)
1430
+ !@current_client.nil? && pane.equal?(focused_target)
1431
+ end
1432
+
1433
+ def clear_focused_attention
1434
+ target = focused_target
1435
+ target.clear_attention! if target.respond_to?(:clear_attention!)
1436
+ end
1437
+
1438
+ # Copy any OSC 52 clipboard write the pane's emulator collected to the
1439
+ # system clipboard, and mirror it into the internal paste buffer so Ctrl-a p
1440
+ # pastes the same text (same as copy-mode's yank). Unlike notifications this
1441
+ # runs even when no client is attached — pbcopy is local to the server host,
1442
+ # so a background pane's yank still lands on the clipboard.
1443
+ def forward_clipboard(pane)
1444
+ text = pane.terminal.take_pending_clipboard!
1445
+ return if text.nil? || text.empty?
1446
+ @paste_buffer = text
1447
+ spawn_pbcopy(text)
886
1448
  end
887
1449
 
888
1450
  def pane_for_io(io)
@@ -899,8 +1461,15 @@ module Muxr
899
1461
  nil
900
1462
  end
901
1463
 
1464
+ # A pane whose pty has been sent to another server but whose move is not
1465
+ # committed yet. Its fd stays out of the select sets: two servers reading
1466
+ # one master would split the byte stream between them.
1467
+ def handing_off?(pane)
1468
+ !!@control_server&.handing_off?(pane)
1469
+ end
1470
+
902
1471
  def prune_dead_panes
903
- dead = @session.window.panes.reject(&:alive?)
1472
+ dead = @session.window.panes.reject { |p| p.alive? || handing_off?(p) }
904
1473
  return if dead.empty?
905
1474
  dead.each { |p| @session.window.remove_pane(p) }
906
1475
  invalidate
@@ -940,17 +1509,40 @@ module Muxr
940
1509
  end
941
1510
 
942
1511
  def render
1512
+ leave_stale_scrollback
1513
+ clear_focused_attention
943
1514
  @renderer.render(
944
1515
  @session,
945
1516
  input_state: @input.state,
1517
+ scroll_source: @input.scroll_source,
946
1518
  command_buffer: @input.command_buffer,
1519
+ command_completions: @input.command_completions,
947
1520
  search_buffer: @input.search_buffer,
948
1521
  search_direction: @input.search_direction,
949
1522
  message: @message,
950
- help: @help_visible
1523
+ help: @help_visible,
1524
+ picker: @pane_picker,
1525
+ prefix: @input.prefix
951
1526
  )
952
1527
  end
953
1528
 
1529
+ def leave_stale_scrollback
1530
+ return unless @input.state == :scrollback
1531
+ target = focused_target
1532
+ term = target&.terminal
1533
+ return unless term
1534
+ if @input.scroll_source == :app
1535
+ return if app_scroll_available?(target)
1536
+ @input.enter_scrollback_mode(source: :ring)
1537
+ flash("app exited — scrolling muxr history")
1538
+ @renderer.reset_frame!
1539
+ return
1540
+ end
1541
+ return unless term.alt_screen?
1542
+ @input.enter_idle_mode
1543
+ @renderer.reset_frame!
1544
+ end
1545
+
954
1546
  def disconnect_client(reason: nil)
955
1547
  return unless @current_client
956
1548
  # Best-effort: drop any queued OUTPUT (the client is going away),
@@ -1044,12 +1636,13 @@ module Muxr
1044
1636
  end
1045
1637
 
1046
1638
  def make_pane(cwd: nil, id: nil)
1047
- Pane.new(id: id, rows: 24, cols: 80, cwd: cwd)
1639
+ pane_id = id || SecureRandom.hex(3)
1640
+ Pane.new(id: pane_id, rows: 24, cols: 80, cwd: cwd, env_overrides: pane_env(pane_id))
1048
1641
  end
1049
1642
 
1050
1643
  def ensure_drawer(command: nil)
1051
1644
  return if @session.drawer
1052
- cwd = focused_pane&.cwd
1645
+ cwd = @origin_cwd
1053
1646
  pane = Pane.new(
1054
1647
  id: :drawer,
1055
1648
  rows: 10,
@@ -1084,15 +1677,19 @@ module Muxr
1084
1677
  invalidate
1085
1678
  end
1086
1679
 
1087
- # Env vars exposed to every drawer PTY. The MCP bridge reads these to
1088
- # auto-connect to the right session; MUXR_DRAWER_SELF lets it refuse
1089
- # drawer.* methods so a claude drawer can't recurse into its own PTY.
1090
- def drawer_env
1091
- env = {
1680
+ def session_env
1681
+ {
1092
1682
  "MUXR_SESSION" => @session_name.to_s,
1093
- "MUXR_CONTROL_SOCKET" => @control_socket_path.to_s,
1094
- "MUXR_DRAWER_SELF" => "1"
1683
+ "MUXR_CONTROL_SOCKET" => @control_socket_path.to_s
1095
1684
  }
1685
+ end
1686
+
1687
+ def pane_env(pane_id)
1688
+ session_env.merge("MUXR_PANE" => pane_id.to_s)
1689
+ end
1690
+
1691
+ def drawer_env
1692
+ env = session_env.merge("MUXR_DRAWER_SELF" => "1")
1096
1693
  focused = focused_pane
1097
1694
  env["MUXR_FOCUSED_PANE"] = focused.id.to_s if focused&.id.is_a?(String)
1098
1695
  env
@@ -1117,6 +1714,11 @@ module Muxr
1117
1714
  pane.mark_private! if entry["private"]
1118
1715
  @session.window.add_pane(pane)
1119
1716
  end
1717
+ panes_data.each_with_index do |entry, i|
1718
+ pane = @session.window.panes[i]
1719
+ pane.name = entry["name"] if pane && entry["name"]
1720
+ pane.watch_silence(entry["silence"]) if pane && entry["silence"].is_a?(Integer) && entry["silence"].positive?
1721
+ end
1120
1722
 
1121
1723
  if data["drawer"]
1122
1724
  cwd = data["drawer"]["cwd"]
@@ -1137,6 +1739,8 @@ module Muxr
1137
1739
 
1138
1740
  @session.window.focused_index = (data["focused_index"] || 0).clamp(0, @session.window.panes.length - 1)
1139
1741
  @session.window.master_index = (data["master_index"] || 0).clamp(0, @session.window.panes.length - 1)
1742
+ @session.window.master_ratio = data["master_ratio"] if data["master_ratio"].is_a?(Numeric)
1743
+ @session.window.master_count = data["master_count"] if data["master_count"].is_a?(Integer)
1140
1744
  flash("session restored")
1141
1745
  end
1142
1746