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.
@@ -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 = focused_pane&.cwd
92
- @session.window.add_pane(make_pane(cwd: cwd))
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
- def toggle_drawer
160
- ensure_drawer
161
- @session.drawer.toggle!
162
- @session.focus_drawer = @session.drawer.visible?
163
- @session.focus_drawer = false unless @session.drawer.visible?
164
- renderer.reset_frame!
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
- first_pane = make_pane
485
- @session.window.add_pane(first_pane)
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
- invalidate if data
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
- @next_pane_id += 1
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(id: :drawer, rows: 10, cols: 80, cwd: cwd)
750
- @session.drawer = Drawer.new(pane: pane, origin_cwd: cwd)
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
- def restore_panes_if_saved
754
- data = Session.load(@session_name)
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
- @session.window.add_pane(make_pane(cwd: cwd))
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
- pane = Pane.new(id: :drawer, rows: 10, cols: 80, cwd: cwd)
770
- drawer = Drawer.new(pane: pane, origin_cwd: cwd)
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