muxr 0.1.3 → 0.1.5
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 +84 -1
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +198 -29
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/input_handler.rb +2 -0
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/pane.rb +65 -7
- data/lib/muxr/renderer.rb +22 -4
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +80 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +6 -1
data/lib/muxr/application.rb
CHANGED
|
@@ -16,16 +16,25 @@ module Muxr
|
|
|
16
16
|
# attach via Renderer#reset_frame!.
|
|
17
17
|
class Application
|
|
18
18
|
SELECT_TIMEOUT = 0.05
|
|
19
|
+
# ~60 Hz cap on full repaints. Keystrokes in fzf or vim navigation can
|
|
20
|
+
# trigger PTY bursts faster than the terminal can usefully display them;
|
|
21
|
+
# the cap collapses those bursts and stops intermediate frames from
|
|
22
|
+
# showing through.
|
|
23
|
+
MIN_FRAME_INTERVAL = 1.0 / 60
|
|
19
24
|
SOCKETS_DIR = File.join(Dir.home, ".muxr", "sockets").freeze
|
|
20
25
|
DEFAULT_WIDTH = 80
|
|
21
26
|
DEFAULT_HEIGHT = 24
|
|
22
27
|
|
|
23
|
-
attr_reader :session, :renderer, :input, :session_name
|
|
28
|
+
attr_reader :session, :renderer, :input, :session_name, :control_server
|
|
24
29
|
|
|
25
30
|
def self.socket_path_for(name)
|
|
26
31
|
File.join(SOCKETS_DIR, "#{name}.sock")
|
|
27
32
|
end
|
|
28
33
|
|
|
34
|
+
def self.control_socket_path_for(name)
|
|
35
|
+
File.join(SOCKETS_DIR, "#{name}.ctrl.sock")
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
# Names of sessions whose server socket is currently accepting connections.
|
|
30
39
|
# Stale sockets (file exists, no listener) are skipped but left in place;
|
|
31
40
|
# cleanup happens on the next attach attempt.
|
|
@@ -55,12 +64,14 @@ module Muxr
|
|
|
55
64
|
@message = nil
|
|
56
65
|
@message_expires = nil
|
|
57
66
|
@help_visible = false
|
|
58
|
-
@next_pane_id = 0
|
|
59
67
|
@current_client = nil
|
|
60
68
|
@client_write_buffer = +"".b
|
|
61
69
|
@listening_socket = nil
|
|
62
70
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
71
|
+
@control_socket_path = self.class.control_socket_path_for(@session_name)
|
|
72
|
+
@control_server = nil
|
|
63
73
|
@paste_buffer = +""
|
|
74
|
+
@last_render_at = nil
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
attr_reader :paste_buffer
|
|
@@ -81,12 +92,14 @@ module Muxr
|
|
|
81
92
|
target&.write(data)
|
|
82
93
|
end
|
|
83
94
|
|
|
84
|
-
def new_pane
|
|
85
|
-
cwd
|
|
86
|
-
|
|
95
|
+
def new_pane(cwd: nil)
|
|
96
|
+
cwd ||= focused_pane&.cwd
|
|
97
|
+
pane = make_pane(cwd: cwd)
|
|
98
|
+
@session.window.add_pane(pane)
|
|
87
99
|
@session.focus_drawer = false
|
|
88
100
|
@session.window.focused_index = @session.window.panes.length - 1
|
|
89
101
|
invalidate
|
|
102
|
+
pane
|
|
90
103
|
end
|
|
91
104
|
|
|
92
105
|
def focus_next
|
|
@@ -150,15 +163,30 @@ module Muxr
|
|
|
150
163
|
invalidate
|
|
151
164
|
end
|
|
152
165
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
166
|
+
# Toggle the privacy flag on the focused pane. Private panes are
|
|
167
|
+
# redacted from the MCP control surface (panes.list strips cwd; read /
|
|
168
|
+
# send_input / run / subscribe / kill all refuse). Only the human can
|
|
169
|
+
# flip this — there is intentionally no control method to do it.
|
|
170
|
+
def toggle_private_focused
|
|
171
|
+
pane = focused_pane
|
|
172
|
+
return unless pane
|
|
173
|
+
pane.toggle_private!
|
|
174
|
+
flash(pane.private? ? "pane #{pane.id} marked private (hidden from MCP)" : "pane #{pane.id} unmarked private")
|
|
159
175
|
invalidate
|
|
160
176
|
end
|
|
161
177
|
|
|
178
|
+
def toggle_drawer
|
|
179
|
+
toggle_drawer_kind(command: nil)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Ctrl-a C / :claude — opens a drawer whose shell is `claude`, with
|
|
183
|
+
# MUXR_SESSION + MUXR_CONTROL_SOCKET + MUXR_FOCUSED_PANE in the env so
|
|
184
|
+
# the muxr-mcp bridge inside that claude process auto-attaches to this
|
|
185
|
+
# session.
|
|
186
|
+
def toggle_claude_drawer
|
|
187
|
+
toggle_drawer_kind(command: "claude")
|
|
188
|
+
end
|
|
189
|
+
|
|
162
190
|
def show_drawer
|
|
163
191
|
ensure_drawer
|
|
164
192
|
@session.drawer.show!
|
|
@@ -471,20 +499,30 @@ module Muxr
|
|
|
471
499
|
@listening_socket = UNIXServer.new(@socket_path)
|
|
472
500
|
File.chmod(0o600, @socket_path) rescue nil
|
|
473
501
|
|
|
502
|
+
# Sibling control socket — multi-client, NDJSON, used by bin/muxr-mcp
|
|
503
|
+
# and any other programmatic driver. Connected control clients do not
|
|
504
|
+
# count as "attached", so a Claude Code session can poke the muxr
|
|
505
|
+
# server without contending with the human's TTY client.
|
|
506
|
+
@control_server = ControlServer.new(self, @control_socket_path)
|
|
507
|
+
@control_server.start
|
|
508
|
+
|
|
474
509
|
@session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
475
510
|
@renderer = Renderer.new(out: FramedOutput.new(self))
|
|
476
511
|
@input = InputHandler.new(self)
|
|
477
512
|
|
|
478
|
-
|
|
479
|
-
|
|
513
|
+
saved = Session.load(@session_name)
|
|
514
|
+
first_id = saved && saved.dig("panes", 0, "id")
|
|
515
|
+
@session.window.add_pane(make_pane(id: first_id))
|
|
480
516
|
|
|
481
|
-
restore_panes_if_saved
|
|
517
|
+
restore_panes_if_saved(saved) if saved
|
|
482
518
|
|
|
483
519
|
@running = true
|
|
484
520
|
end
|
|
485
521
|
|
|
486
522
|
def teardown
|
|
487
523
|
disconnect_client
|
|
524
|
+
@control_server&.stop
|
|
525
|
+
@control_server = nil
|
|
488
526
|
if @listening_socket
|
|
489
527
|
@listening_socket.close rescue nil
|
|
490
528
|
end
|
|
@@ -502,6 +540,7 @@ module Muxr
|
|
|
502
540
|
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
503
541
|
drawer_pane = @session.drawer&.pane
|
|
504
542
|
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
543
|
+
read_ios.concat(@control_server.read_ios) if @control_server
|
|
505
544
|
|
|
506
545
|
write_ios = []
|
|
507
546
|
@session.window.panes.each do |p|
|
|
@@ -511,8 +550,23 @@ module Muxr
|
|
|
511
550
|
write_ios << drawer_pane.writer_io
|
|
512
551
|
end
|
|
513
552
|
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
553
|
+
write_ios.concat(@control_server.write_ios) if @control_server
|
|
514
554
|
|
|
515
555
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
556
|
+
# If a render is queued but we're inside the frame-rate budget, wake
|
|
557
|
+
# up as soon as the budget expires so the deferred paint lands on time.
|
|
558
|
+
if @current_client && @needs_render && @last_render_at
|
|
559
|
+
budget = MIN_FRAME_INTERVAL - (monotonic_now - @last_render_at)
|
|
560
|
+
timeout = budget.clamp(0, timeout) if budget < timeout
|
|
561
|
+
end
|
|
562
|
+
# If a pane is mid-synchronized-output (DEC 2026), wake up no later
|
|
563
|
+
# than its safety deadline so a crashed inner program can't wedge
|
|
564
|
+
# rendering past Terminal::SYNC_TIMEOUT.
|
|
565
|
+
deadline = nearest_sync_deadline
|
|
566
|
+
if deadline
|
|
567
|
+
remaining = deadline - monotonic_now
|
|
568
|
+
timeout = remaining.clamp(0, timeout) if remaining < timeout
|
|
569
|
+
end
|
|
516
570
|
ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
|
|
517
571
|
|
|
518
572
|
ready_r&.each do |io|
|
|
@@ -520,6 +574,8 @@ module Muxr
|
|
|
520
574
|
accept_client
|
|
521
575
|
elsif io == @current_client
|
|
522
576
|
consume_client_frame
|
|
577
|
+
elsif @control_server&.owns?(io)
|
|
578
|
+
@control_server.handle_read(io)
|
|
523
579
|
else
|
|
524
580
|
consume_pane_io(io)
|
|
525
581
|
end
|
|
@@ -528,13 +584,18 @@ module Muxr
|
|
|
528
584
|
ready_w&.each do |io|
|
|
529
585
|
if io == @current_client
|
|
530
586
|
drain_client_writes
|
|
587
|
+
elsif @control_server&.owns?(io)
|
|
588
|
+
@control_server.handle_write(io)
|
|
531
589
|
else
|
|
532
590
|
pane = pane_for_writer_io(io)
|
|
533
591
|
pane&.drain_writes
|
|
534
592
|
end
|
|
535
593
|
end
|
|
536
594
|
|
|
595
|
+
@control_server&.tick
|
|
596
|
+
|
|
537
597
|
prune_dead_panes
|
|
598
|
+
prune_dead_drawer
|
|
538
599
|
expire_message
|
|
539
600
|
|
|
540
601
|
if @session.window.panes.empty?
|
|
@@ -542,13 +603,38 @@ module Muxr
|
|
|
542
603
|
break
|
|
543
604
|
end
|
|
544
605
|
|
|
545
|
-
if @current_client && @needs_render
|
|
546
|
-
|
|
547
|
-
@
|
|
606
|
+
if @current_client && @needs_render && !any_pane_syncing?
|
|
607
|
+
now = monotonic_now
|
|
608
|
+
if @last_render_at.nil? || (now - @last_render_at) >= MIN_FRAME_INTERVAL
|
|
609
|
+
render
|
|
610
|
+
@last_render_at = now
|
|
611
|
+
@needs_render = false
|
|
612
|
+
end
|
|
548
613
|
end
|
|
549
614
|
end
|
|
550
615
|
end
|
|
551
616
|
|
|
617
|
+
def monotonic_now
|
|
618
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# True iff any pane (or the drawer) has opened a DEC 2026 synchronized
|
|
622
|
+
# output block that hasn't yet closed or timed out. Used to defer the
|
|
623
|
+
# outer paint so it lands on a fully-formed inner frame.
|
|
624
|
+
def any_pane_syncing?
|
|
625
|
+
return true if @session.window.panes.any? { |p| p.terminal.sync_pending? }
|
|
626
|
+
drawer = @session.drawer&.pane
|
|
627
|
+
return true if drawer && drawer.terminal.sync_pending?
|
|
628
|
+
false
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def nearest_sync_deadline
|
|
632
|
+
deadlines = @session.window.panes.filter_map { |p| p.terminal.sync_deadline }
|
|
633
|
+
d = @session.drawer&.pane&.terminal&.sync_deadline
|
|
634
|
+
deadlines << d if d
|
|
635
|
+
deadlines.min
|
|
636
|
+
end
|
|
637
|
+
|
|
552
638
|
def accept_client
|
|
553
639
|
sock = @listening_socket.accept
|
|
554
640
|
if @current_client
|
|
@@ -602,7 +688,14 @@ module Muxr
|
|
|
602
688
|
pane = pane_for_io(io)
|
|
603
689
|
return unless pane
|
|
604
690
|
data = pane.read_from_pty
|
|
605
|
-
|
|
691
|
+
if data
|
|
692
|
+
invalidate
|
|
693
|
+
# Notify the control surface so any pending pane.run waiters reset
|
|
694
|
+
# their idle window and any pane.subscribe clients get a new frame.
|
|
695
|
+
# read_from_pty already fed the bytes into the Terminal; the control
|
|
696
|
+
# server pulls the resulting text out of pane.terminal.dump_text.
|
|
697
|
+
@control_server&.on_pane_output(pane.id, data) if pane.id.is_a?(String)
|
|
698
|
+
end
|
|
606
699
|
end
|
|
607
700
|
|
|
608
701
|
def pane_for_io(io)
|
|
@@ -626,6 +719,25 @@ module Muxr
|
|
|
626
719
|
invalidate
|
|
627
720
|
end
|
|
628
721
|
|
|
722
|
+
# When the shell (or claude) inside the drawer exits, tear the drawer
|
|
723
|
+
# down so the next Ctrl-a ~ / Ctrl-a C spawns a fresh one. Without this
|
|
724
|
+
# the drawer pane stays mounted around a dead PTY and looks like the
|
|
725
|
+
# multiplexer is wedged.
|
|
726
|
+
def prune_dead_drawer
|
|
727
|
+
drawer = @session.drawer
|
|
728
|
+
return unless drawer
|
|
729
|
+
pane = drawer.pane
|
|
730
|
+
return unless pane
|
|
731
|
+
return if pane.alive?
|
|
732
|
+
kind = drawer.command ? "#{drawer.command} drawer" : "drawer"
|
|
733
|
+
drawer.close
|
|
734
|
+
@session.drawer = nil
|
|
735
|
+
@session.focus_drawer = false
|
|
736
|
+
renderer.reset_frame!
|
|
737
|
+
flash("#{kind} exited")
|
|
738
|
+
invalidate
|
|
739
|
+
end
|
|
740
|
+
|
|
629
741
|
def expire_message
|
|
630
742
|
return unless @message_expires
|
|
631
743
|
if Time.now >= @message_expires
|
|
@@ -693,20 +805,62 @@ module Muxr
|
|
|
693
805
|
end
|
|
694
806
|
end
|
|
695
807
|
|
|
696
|
-
def make_pane(cwd: nil)
|
|
697
|
-
|
|
698
|
-
Pane.new(id: @next_pane_id, rows: 24, cols: 80, cwd: cwd)
|
|
808
|
+
def make_pane(cwd: nil, id: nil)
|
|
809
|
+
Pane.new(id: id, rows: 24, cols: 80, cwd: cwd)
|
|
699
810
|
end
|
|
700
811
|
|
|
701
|
-
def ensure_drawer
|
|
812
|
+
def ensure_drawer(command: nil)
|
|
702
813
|
return if @session.drawer
|
|
703
814
|
cwd = focused_pane&.cwd
|
|
704
|
-
pane = Pane.new(
|
|
705
|
-
|
|
815
|
+
pane = Pane.new(
|
|
816
|
+
id: :drawer,
|
|
817
|
+
rows: 10,
|
|
818
|
+
cols: 80,
|
|
819
|
+
cwd: cwd,
|
|
820
|
+
command: command,
|
|
821
|
+
env_overrides: drawer_env
|
|
822
|
+
)
|
|
823
|
+
@session.drawer = Drawer.new(pane: pane, origin_cwd: cwd, command: command)
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Toggle the drawer; if a different kind is currently up, tear it down
|
|
827
|
+
# and replace it with the requested kind. Keeps the drawer slot a single
|
|
828
|
+
# PTY so users don't end up with a confusing menagerie of overlays.
|
|
829
|
+
def toggle_drawer_kind(command:)
|
|
830
|
+
current = @session.drawer
|
|
831
|
+
if current.nil?
|
|
832
|
+
ensure_drawer(command: command)
|
|
833
|
+
@session.drawer.show!
|
|
834
|
+
@session.focus_drawer = true
|
|
835
|
+
elsif current.command == command
|
|
836
|
+
current.toggle!
|
|
837
|
+
@session.focus_drawer = current.visible?
|
|
838
|
+
else
|
|
839
|
+
current.close
|
|
840
|
+
@session.drawer = nil
|
|
841
|
+
ensure_drawer(command: command)
|
|
842
|
+
@session.drawer.show!
|
|
843
|
+
@session.focus_drawer = true
|
|
844
|
+
end
|
|
845
|
+
renderer.reset_frame!
|
|
846
|
+
invalidate
|
|
706
847
|
end
|
|
707
848
|
|
|
708
|
-
|
|
709
|
-
|
|
849
|
+
# Env vars exposed to every drawer PTY. The MCP bridge reads these to
|
|
850
|
+
# auto-connect to the right session; MUXR_DRAWER_SELF lets it refuse
|
|
851
|
+
# drawer.* methods so a claude drawer can't recurse into its own PTY.
|
|
852
|
+
def drawer_env
|
|
853
|
+
env = {
|
|
854
|
+
"MUXR_SESSION" => @session_name.to_s,
|
|
855
|
+
"MUXR_CONTROL_SOCKET" => @control_socket_path.to_s,
|
|
856
|
+
"MUXR_DRAWER_SELF" => "1"
|
|
857
|
+
}
|
|
858
|
+
focused = focused_pane
|
|
859
|
+
env["MUXR_FOCUSED_PANE"] = focused.id.to_s if focused&.id.is_a?(String)
|
|
860
|
+
env
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
def restore_panes_if_saved(data)
|
|
710
864
|
return unless data
|
|
711
865
|
|
|
712
866
|
if data["layout"] && Window::LAYOUTS.include?(data["layout"].to_sym)
|
|
@@ -714,15 +868,30 @@ module Muxr
|
|
|
714
868
|
end
|
|
715
869
|
|
|
716
870
|
panes_data = data["panes"] || []
|
|
871
|
+
# Restore privacy flag for the already-created first pane.
|
|
872
|
+
if panes_data[0] && panes_data[0]["private"] && @session.window.panes[0]
|
|
873
|
+
@session.window.panes[0].mark_private!
|
|
874
|
+
end
|
|
717
875
|
panes_data[1..]&.each do |entry|
|
|
718
876
|
cwd = entry["cwd"]
|
|
719
|
-
|
|
877
|
+
id = entry["id"]
|
|
878
|
+
pane = make_pane(cwd: cwd, id: id)
|
|
879
|
+
pane.mark_private! if entry["private"]
|
|
880
|
+
@session.window.add_pane(pane)
|
|
720
881
|
end
|
|
721
882
|
|
|
722
883
|
if data["drawer"]
|
|
723
884
|
cwd = data["drawer"]["cwd"]
|
|
724
|
-
|
|
725
|
-
|
|
885
|
+
command = data["drawer"]["command"]
|
|
886
|
+
pane = Pane.new(
|
|
887
|
+
id: :drawer,
|
|
888
|
+
rows: 10,
|
|
889
|
+
cols: 80,
|
|
890
|
+
cwd: cwd,
|
|
891
|
+
command: command,
|
|
892
|
+
env_overrides: drawer_env
|
|
893
|
+
)
|
|
894
|
+
drawer = Drawer.new(pane: pane, origin_cwd: cwd, command: command)
|
|
726
895
|
drawer.visible = !!data["drawer"]["visible"]
|
|
727
896
|
@session.drawer = drawer
|
|
728
897
|
@session.focus_drawer = drawer.visible?
|
|
@@ -16,6 +16,8 @@ module Muxr
|
|
|
16
16
|
case cmd
|
|
17
17
|
when "layout" then handle_layout(args)
|
|
18
18
|
when "drawer" then handle_drawer(args)
|
|
19
|
+
when "claude" then @app.toggle_claude_drawer
|
|
20
|
+
when "private" then @app.toggle_private_focused
|
|
19
21
|
when "save" then @app.save_session
|
|
20
22
|
when "restore" then @app.restore_session
|
|
21
23
|
when "sessions", "ls" then @app.list_sessions
|