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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
data/lib/muxr/application.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
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 (
|
|
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
|
-
|
|
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?("-") } ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
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
|
|