muxr 0.1.4 → 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 +65 -1
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +150 -26
- 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 +50 -3
- data/lib/muxr/renderer.rb +14 -3
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +37 -0
- 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
|
@@ -25,12 +25,16 @@ module Muxr
|
|
|
25
25
|
DEFAULT_WIDTH = 80
|
|
26
26
|
DEFAULT_HEIGHT = 24
|
|
27
27
|
|
|
28
|
-
attr_reader :session, :renderer, :input, :session_name
|
|
28
|
+
attr_reader :session, :renderer, :input, :session_name, :control_server
|
|
29
29
|
|
|
30
30
|
def self.socket_path_for(name)
|
|
31
31
|
File.join(SOCKETS_DIR, "#{name}.sock")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def self.control_socket_path_for(name)
|
|
35
|
+
File.join(SOCKETS_DIR, "#{name}.ctrl.sock")
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
# Names of sessions whose server socket is currently accepting connections.
|
|
35
39
|
# Stale sockets (file exists, no listener) are skipped but left in place;
|
|
36
40
|
# cleanup happens on the next attach attempt.
|
|
@@ -60,11 +64,12 @@ module Muxr
|
|
|
60
64
|
@message = nil
|
|
61
65
|
@message_expires = nil
|
|
62
66
|
@help_visible = false
|
|
63
|
-
@next_pane_id = 0
|
|
64
67
|
@current_client = nil
|
|
65
68
|
@client_write_buffer = +"".b
|
|
66
69
|
@listening_socket = nil
|
|
67
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
|
|
68
73
|
@paste_buffer = +""
|
|
69
74
|
@last_render_at = nil
|
|
70
75
|
end
|
|
@@ -87,12 +92,14 @@ module Muxr
|
|
|
87
92
|
target&.write(data)
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
def new_pane
|
|
91
|
-
cwd
|
|
92
|
-
|
|
95
|
+
def new_pane(cwd: nil)
|
|
96
|
+
cwd ||= focused_pane&.cwd
|
|
97
|
+
pane = make_pane(cwd: cwd)
|
|
98
|
+
@session.window.add_pane(pane)
|
|
93
99
|
@session.focus_drawer = false
|
|
94
100
|
@session.window.focused_index = @session.window.panes.length - 1
|
|
95
101
|
invalidate
|
|
102
|
+
pane
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
def focus_next
|
|
@@ -156,15 +163,30 @@ module Muxr
|
|
|
156
163
|
invalidate
|
|
157
164
|
end
|
|
158
165
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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")
|
|
165
175
|
invalidate
|
|
166
176
|
end
|
|
167
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
|
+
|
|
168
190
|
def show_drawer
|
|
169
191
|
ensure_drawer
|
|
170
192
|
@session.drawer.show!
|
|
@@ -477,20 +499,30 @@ module Muxr
|
|
|
477
499
|
@listening_socket = UNIXServer.new(@socket_path)
|
|
478
500
|
File.chmod(0o600, @socket_path) rescue nil
|
|
479
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
|
+
|
|
480
509
|
@session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
481
510
|
@renderer = Renderer.new(out: FramedOutput.new(self))
|
|
482
511
|
@input = InputHandler.new(self)
|
|
483
512
|
|
|
484
|
-
|
|
485
|
-
|
|
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))
|
|
486
516
|
|
|
487
|
-
restore_panes_if_saved
|
|
517
|
+
restore_panes_if_saved(saved) if saved
|
|
488
518
|
|
|
489
519
|
@running = true
|
|
490
520
|
end
|
|
491
521
|
|
|
492
522
|
def teardown
|
|
493
523
|
disconnect_client
|
|
524
|
+
@control_server&.stop
|
|
525
|
+
@control_server = nil
|
|
494
526
|
if @listening_socket
|
|
495
527
|
@listening_socket.close rescue nil
|
|
496
528
|
end
|
|
@@ -508,6 +540,7 @@ module Muxr
|
|
|
508
540
|
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
509
541
|
drawer_pane = @session.drawer&.pane
|
|
510
542
|
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
543
|
+
read_ios.concat(@control_server.read_ios) if @control_server
|
|
511
544
|
|
|
512
545
|
write_ios = []
|
|
513
546
|
@session.window.panes.each do |p|
|
|
@@ -517,6 +550,7 @@ module Muxr
|
|
|
517
550
|
write_ios << drawer_pane.writer_io
|
|
518
551
|
end
|
|
519
552
|
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
553
|
+
write_ios.concat(@control_server.write_ios) if @control_server
|
|
520
554
|
|
|
521
555
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
522
556
|
# If a render is queued but we're inside the frame-rate budget, wake
|
|
@@ -540,6 +574,8 @@ module Muxr
|
|
|
540
574
|
accept_client
|
|
541
575
|
elsif io == @current_client
|
|
542
576
|
consume_client_frame
|
|
577
|
+
elsif @control_server&.owns?(io)
|
|
578
|
+
@control_server.handle_read(io)
|
|
543
579
|
else
|
|
544
580
|
consume_pane_io(io)
|
|
545
581
|
end
|
|
@@ -548,13 +584,18 @@ module Muxr
|
|
|
548
584
|
ready_w&.each do |io|
|
|
549
585
|
if io == @current_client
|
|
550
586
|
drain_client_writes
|
|
587
|
+
elsif @control_server&.owns?(io)
|
|
588
|
+
@control_server.handle_write(io)
|
|
551
589
|
else
|
|
552
590
|
pane = pane_for_writer_io(io)
|
|
553
591
|
pane&.drain_writes
|
|
554
592
|
end
|
|
555
593
|
end
|
|
556
594
|
|
|
595
|
+
@control_server&.tick
|
|
596
|
+
|
|
557
597
|
prune_dead_panes
|
|
598
|
+
prune_dead_drawer
|
|
558
599
|
expire_message
|
|
559
600
|
|
|
560
601
|
if @session.window.panes.empty?
|
|
@@ -647,7 +688,14 @@ module Muxr
|
|
|
647
688
|
pane = pane_for_io(io)
|
|
648
689
|
return unless pane
|
|
649
690
|
data = pane.read_from_pty
|
|
650
|
-
|
|
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
|
|
651
699
|
end
|
|
652
700
|
|
|
653
701
|
def pane_for_io(io)
|
|
@@ -671,6 +719,25 @@ module Muxr
|
|
|
671
719
|
invalidate
|
|
672
720
|
end
|
|
673
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
|
+
|
|
674
741
|
def expire_message
|
|
675
742
|
return unless @message_expires
|
|
676
743
|
if Time.now >= @message_expires
|
|
@@ -738,20 +805,62 @@ module Muxr
|
|
|
738
805
|
end
|
|
739
806
|
end
|
|
740
807
|
|
|
741
|
-
def make_pane(cwd: nil)
|
|
742
|
-
|
|
743
|
-
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)
|
|
744
810
|
end
|
|
745
811
|
|
|
746
|
-
def ensure_drawer
|
|
812
|
+
def ensure_drawer(command: nil)
|
|
747
813
|
return if @session.drawer
|
|
748
814
|
cwd = focused_pane&.cwd
|
|
749
|
-
pane = Pane.new(
|
|
750
|
-
|
|
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
|
|
751
847
|
end
|
|
752
848
|
|
|
753
|
-
|
|
754
|
-
|
|
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)
|
|
755
864
|
return unless data
|
|
756
865
|
|
|
757
866
|
if data["layout"] && Window::LAYOUTS.include?(data["layout"].to_sym)
|
|
@@ -759,15 +868,30 @@ module Muxr
|
|
|
759
868
|
end
|
|
760
869
|
|
|
761
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
|
|
762
875
|
panes_data[1..]&.each do |entry|
|
|
763
876
|
cwd = entry["cwd"]
|
|
764
|
-
|
|
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)
|
|
765
881
|
end
|
|
766
882
|
|
|
767
883
|
if data["drawer"]
|
|
768
884
|
cwd = data["drawer"]["cwd"]
|
|
769
|
-
|
|
770
|
-
|
|
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)
|
|
771
895
|
drawer.visible = !!data["drawer"]["visible"]
|
|
772
896
|
@session.drawer = drawer
|
|
773
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
|