muxr 0.1.11 → 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,6 +84,7 @@ 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
@@ -127,7 +148,59 @@ module Muxr
127
148
  target = focused_target
128
149
  return unless target
129
150
  data = strip_bracketed_paste_markers(data, target)
130
- target.write(data) unless data.empty?
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
131
204
  end
132
205
 
133
206
  # The client turns bracketed-paste mode on for the *outer* terminal so big
@@ -236,9 +309,13 @@ module Muxr
236
309
  # you hop onto a live pane, so we never auto-leave.
237
310
  def sync_input_mode_to_focus
238
311
  target = focused_target
239
- return unless target&.terminal&.scrolled_back?
240
- @input.enter_scrollback_mode
241
- @renderer.reset_frame!
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
242
319
  end
243
320
 
244
321
  # Move focus to the pane spatially adjacent in `direction` (:left/:right/
@@ -317,7 +394,7 @@ module Muxr
317
394
  # Ctrl-a-prefixed multiplexer mode.
318
395
  def enter_passthrough_mode
319
396
  @input.enter_passthrough_mode
320
- flash("passthrough mode (^a esc to return)")
397
+ flash("passthrough mode (^#{Renderer.prefix_letter(@input.prefix)} esc to return)")
321
398
  invalidate
322
399
  end
323
400
 
@@ -364,6 +441,15 @@ module Muxr
364
441
  invalidate
365
442
  end
366
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
+
367
453
  def cycle_layout
368
454
  @session.window.cycle_layout
369
455
  flash("layout: #{@session.window.layout}")
@@ -392,6 +478,49 @@ module Muxr
392
478
  invalidate
393
479
  end
394
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
+
395
524
  # Toggle the privacy flag on the focused pane. Private panes are
396
525
  # redacted from the MCP control surface (panes.list strips cwd; read /
397
526
  # send_input / run / subscribe / kill all refuse). Only the human can
@@ -483,6 +612,103 @@ module Muxr
483
612
  invalidate
484
613
  end
485
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
+
486
712
  def show_help
487
713
  @help_visible = true
488
714
  @input.enter_help_mode
@@ -497,13 +723,47 @@ module Muxr
497
723
  def enter_scrollback
498
724
  target = focused_target
499
725
  return unless target
500
- @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
501
760
  @renderer.reset_frame!
502
761
  invalidate
503
762
  end
504
763
 
505
764
  def exit_scrollback
506
765
  target = focused_target
766
+ target&.terminal&.confine_selection_to_screen!(false)
507
767
  target&.terminal&.clear_selection
508
768
  target&.terminal&.clear_search
509
769
  target&.terminal&.scroll_to_bottom
@@ -515,6 +775,10 @@ module Muxr
515
775
  # the user into a buffered prompt; commit_search / cancel_search exit
516
776
  # back to scrollback.
517
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
518
782
  @input.enter_search_mode(direction: direction)
519
783
  invalidate
520
784
  end
@@ -553,6 +817,10 @@ module Muxr
553
817
  def step_search(direction)
554
818
  target = focused_target
555
819
  return unless target
820
+ if @input.scroll_source == :app
821
+ flash("search reads muxr history — Tab to switch")
822
+ return
823
+ end
556
824
  term = target.terminal
557
825
  if term.search_matches.empty?
558
826
  flash("no search active")
@@ -562,9 +830,12 @@ module Muxr
562
830
  invalidate
563
831
  end
564
832
 
833
+ WHEEL_BURST_MAX = 200
834
+
565
835
  def scroll_focused(action)
566
836
  target = focused_target
567
837
  return unless target
838
+ return scroll_app(target, action) if @input.scroll_source == :app
568
839
  term = target.terminal
569
840
  rows = term.rows
570
841
  case action
@@ -580,6 +851,48 @@ module Muxr
580
851
  invalidate
581
852
  end
582
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
+
583
896
  def enter_selection
584
897
  target = focused_target
585
898
  return unless target
@@ -588,6 +901,7 @@ module Muxr
588
901
  # anchor. Start at the live cursor's visible position so the user lands
589
902
  # where their attention already is, instead of the top-left corner.
590
903
  term = target.terminal
904
+ term.confine_selection_to_screen!(@input.scroll_source == :app)
591
905
  term.place_selection_cursor(term.cursor_row, term.cursor_col)
592
906
  @input.enter_selection_mode
593
907
  @renderer.reset_frame!
@@ -668,10 +982,33 @@ module Muxr
668
982
  invalidate
669
983
  end
670
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
+
671
1009
  def paste_from_buffer
672
1010
  return if @paste_buffer.nil? || @paste_buffer.empty?
673
- target = focused_target
674
- target&.write(@paste_buffer)
1011
+ input_targets.each { |pane| pane.write(@paste_buffer) }
675
1012
  end
676
1013
 
677
1014
  def flash(msg)
@@ -684,6 +1021,43 @@ module Muxr
684
1021
  @needs_render = true
685
1022
  end
686
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
+
687
1061
  def save_session
688
1062
  path = @session.save
689
1063
  flash("saved: #{path}")
@@ -699,7 +1073,7 @@ module Muxr
699
1073
  end
700
1074
 
701
1075
  def list_sessions
702
- names = Session.list
1076
+ names = (Session.list | self.class.list_active).sort
703
1077
  if names.empty?
704
1078
  flash("no saved sessions")
705
1079
  else
@@ -755,7 +1129,7 @@ module Muxr
755
1129
  if idx && argv[idx + 1]
756
1130
  argv[idx + 1]
757
1131
  else
758
- argv.find { |a| !a.start_with?("-") } || "default"
1132
+ argv.find { |a| !a.start_with?("-") } || self.class.default_session_name
759
1133
  end
760
1134
  end
761
1135
 
@@ -777,7 +1151,7 @@ module Muxr
777
1151
  win.panes.length,
778
1152
  area,
779
1153
  focused_index: win.focused_index,
780
- master_index: win.master_index
1154
+ **win.layout_options
781
1155
  )
782
1156
  end
783
1157
 
@@ -804,6 +1178,8 @@ module Muxr
804
1178
  @session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
805
1179
  @renderer = Renderer.new(out: FramedOutput.new(self))
806
1180
  @input = InputHandler.new(self)
1181
+ apply_config(Config.load)
1182
+ apply_window_defaults
807
1183
 
808
1184
  saved = Session.load(@session_name)
809
1185
  first_id = saved && saved.dig("panes", 0, "id")
@@ -838,14 +1214,14 @@ module Muxr
838
1214
  while @running
839
1215
  read_ios = [@listening_socket]
840
1216
  read_ios << @current_client if @current_client
841
- @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) }
842
1218
  drawer_pane = @session.drawer&.pane
843
1219
  read_ios << drawer_pane.io if drawer_pane&.alive?
844
1220
  read_ios.concat(@control_server.read_ios) if @control_server
845
1221
 
846
1222
  write_ios = []
847
1223
  @session.window.panes.each do |p|
848
- 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)
849
1225
  end
850
1226
  if drawer_pane&.alive? && drawer_pane.pending_write?
851
1227
  write_ios << drawer_pane.writer_io
@@ -897,6 +1273,7 @@ module Muxr
897
1273
 
898
1274
  prune_dead_panes
899
1275
  prune_dead_drawer
1276
+ report_silent_panes
900
1277
  expire_message
901
1278
 
902
1279
  if @session.window.panes.empty?
@@ -954,12 +1331,30 @@ module Muxr
954
1331
 
955
1332
  size = Protocol.decode_size(payload)
956
1333
  apply_size(*size) if size
1334
+ apply_caps(Protocol.decode_caps(payload))
957
1335
 
958
1336
  @current_client = sock
959
1337
  @renderer.reset_frame!
1338
+ report_config_problems
960
1339
  invalidate
961
1340
  end
962
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
+
963
1358
  def consume_client_frame
964
1359
  type, payload = Protocol.read(@current_client)
965
1360
  if type.nil?
@@ -988,15 +1383,68 @@ module Muxr
988
1383
  def consume_pane_io(io)
989
1384
  pane = pane_for_io(io)
990
1385
  return unless pane
991
- 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
992
1394
  if data
993
1395
  invalidate
1396
+ pane.note_output(attended: attended?(pane))
994
1397
  # Notify the control surface so any pending pane.run waiters reset
995
1398
  # their idle window and any pane.subscribe clients get a new frame.
996
1399
  # read_from_pty already fed the bytes into the Terminal; the control
997
1400
  # server pulls the resulting text out of pane.terminal.dump_text.
998
1401
  @control_server&.on_pane_output(pane.id, data) if pane.id.is_a?(String)
999
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)
1000
1448
  end
1001
1449
 
1002
1450
  def pane_for_io(io)
@@ -1013,8 +1461,15 @@ module Muxr
1013
1461
  nil
1014
1462
  end
1015
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
+
1016
1471
  def prune_dead_panes
1017
- dead = @session.window.panes.reject(&:alive?)
1472
+ dead = @session.window.panes.reject { |p| p.alive? || handing_off?(p) }
1018
1473
  return if dead.empty?
1019
1474
  dead.each { |p| @session.window.remove_pane(p) }
1020
1475
  invalidate
@@ -1054,17 +1509,40 @@ module Muxr
1054
1509
  end
1055
1510
 
1056
1511
  def render
1512
+ leave_stale_scrollback
1513
+ clear_focused_attention
1057
1514
  @renderer.render(
1058
1515
  @session,
1059
1516
  input_state: @input.state,
1517
+ scroll_source: @input.scroll_source,
1060
1518
  command_buffer: @input.command_buffer,
1519
+ command_completions: @input.command_completions,
1061
1520
  search_buffer: @input.search_buffer,
1062
1521
  search_direction: @input.search_direction,
1063
1522
  message: @message,
1064
- help: @help_visible
1523
+ help: @help_visible,
1524
+ picker: @pane_picker,
1525
+ prefix: @input.prefix
1065
1526
  )
1066
1527
  end
1067
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
+
1068
1546
  def disconnect_client(reason: nil)
1069
1547
  return unless @current_client
1070
1548
  # Best-effort: drop any queued OUTPUT (the client is going away),
@@ -1158,7 +1636,8 @@ module Muxr
1158
1636
  end
1159
1637
 
1160
1638
  def make_pane(cwd: nil, id: nil)
1161
- 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))
1162
1641
  end
1163
1642
 
1164
1643
  def ensure_drawer(command: nil)
@@ -1198,15 +1677,19 @@ module Muxr
1198
1677
  invalidate
1199
1678
  end
1200
1679
 
1201
- # Env vars exposed to every drawer PTY. The MCP bridge reads these to
1202
- # auto-connect to the right session; MUXR_DRAWER_SELF lets it refuse
1203
- # drawer.* methods so a claude drawer can't recurse into its own PTY.
1204
- def drawer_env
1205
- env = {
1680
+ def session_env
1681
+ {
1206
1682
  "MUXR_SESSION" => @session_name.to_s,
1207
- "MUXR_CONTROL_SOCKET" => @control_socket_path.to_s,
1208
- "MUXR_DRAWER_SELF" => "1"
1683
+ "MUXR_CONTROL_SOCKET" => @control_socket_path.to_s
1209
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")
1210
1693
  focused = focused_pane
1211
1694
  env["MUXR_FOCUSED_PANE"] = focused.id.to_s if focused&.id.is_a?(String)
1212
1695
  env
@@ -1231,6 +1714,11 @@ module Muxr
1231
1714
  pane.mark_private! if entry["private"]
1232
1715
  @session.window.add_pane(pane)
1233
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
1234
1722
 
1235
1723
  if data["drawer"]
1236
1724
  cwd = data["drawer"]["cwd"]
@@ -1251,6 +1739,8 @@ module Muxr
1251
1739
 
1252
1740
  @session.window.focused_index = (data["focused_index"] || 0).clamp(0, @session.window.panes.length - 1)
1253
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)
1254
1744
  flash("session restored")
1255
1745
  end
1256
1746