muxr 0.1.4 → 0.1.6

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,15 +64,23 @@ 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
75
+ @foreground_poller = nil
70
76
  end
71
77
 
78
+ # Interval for the background thread that refreshes each pane's
79
+ # foreground-command label. Picked to feel responsive (a long-running
80
+ # `npm test` shows up within a second of starting) without burning CPU
81
+ # on macOS, where each tick costs a `ps` fork+exec per pane.
82
+ FOREGROUND_POLL_INTERVAL = 0.75
83
+
72
84
  attr_reader :paste_buffer
73
85
 
74
86
  def run
@@ -87,12 +99,14 @@ module Muxr
87
99
  target&.write(data)
88
100
  end
89
101
 
90
- def new_pane
91
- cwd = focused_pane&.cwd
92
- @session.window.add_pane(make_pane(cwd: cwd))
102
+ def new_pane(cwd: nil)
103
+ cwd ||= focused_pane&.cwd
104
+ pane = make_pane(cwd: cwd)
105
+ @session.window.add_pane(pane)
93
106
  @session.focus_drawer = false
94
107
  @session.window.focused_index = @session.window.panes.length - 1
95
108
  invalidate
109
+ pane
96
110
  end
97
111
 
98
112
  def focus_next
@@ -134,6 +148,60 @@ module Muxr
134
148
  invalidate
135
149
  end
136
150
 
151
+ # Move focus to the pane spatially adjacent in `direction` (:left/:right/
152
+ # :up/:down). Called by the normal-mode hjkl bindings. Pulling the live
153
+ # layout rects keeps this in sync with whatever the renderer is showing.
154
+ # Monocle has no meaningful direction (every rect is identical) so we
155
+ # fall back to linear nav so hjkl still does something.
156
+ def focus_direction(direction)
157
+ return if @session.window.panes.empty?
158
+ if @session.focus_drawer && @session.drawer&.visible?
159
+ @session.focus_drawer = false
160
+ invalidate
161
+ return
162
+ end
163
+
164
+ win = @session.window
165
+ idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
166
+ if idx.nil? && win.layout == :monocle
167
+ case direction
168
+ when :right, :down then win.focus_next
169
+ when :left, :up then win.focus_prev
170
+ end
171
+ invalidate
172
+ return
173
+ end
174
+
175
+ return unless idx
176
+ win.focus_index(idx)
177
+ invalidate
178
+ end
179
+
180
+ # Explicit layout set, used by the normal-mode t/g/m bindings and the
181
+ # `:layout <name>` command.
182
+ def set_layout(layout)
183
+ @session.window.set_layout(layout)
184
+ flash("layout: #{@session.window.layout}")
185
+ invalidate
186
+ rescue ArgumentError => e
187
+ flash(e.message)
188
+ end
189
+
190
+ # Bound to `i` in normal mode — drops the user into the historical
191
+ # Ctrl-a-prefixed multiplexer mode.
192
+ def enter_passthrough_mode
193
+ @input.enter_passthrough_mode
194
+ flash("passthrough mode (^a esc to return)")
195
+ invalidate
196
+ end
197
+
198
+ # Bound to `Ctrl-a Esc` from passthrough — return to normal mode.
199
+ def enter_normal_mode
200
+ @input.enter_normal_mode
201
+ flash("normal mode")
202
+ invalidate
203
+ end
204
+
137
205
  def close_focused
138
206
  if @session.focus_drawer && @session.drawer&.visible?
139
207
  hide_drawer
@@ -156,15 +224,30 @@ module Muxr
156
224
  invalidate
157
225
  end
158
226
 
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!
227
+ # Toggle the privacy flag on the focused pane. Private panes are
228
+ # redacted from the MCP control surface (panes.list strips cwd; read /
229
+ # send_input / run / subscribe / kill all refuse). Only the human can
230
+ # flip this — there is intentionally no control method to do it.
231
+ def toggle_private_focused
232
+ pane = focused_pane
233
+ return unless pane
234
+ pane.toggle_private!
235
+ flash(pane.private? ? "pane #{pane.id} marked private (hidden from MCP)" : "pane #{pane.id} unmarked private")
165
236
  invalidate
166
237
  end
167
238
 
239
+ def toggle_drawer
240
+ toggle_drawer_kind(command: nil)
241
+ end
242
+
243
+ # Ctrl-a C / :claude — opens a drawer whose shell is `claude`, with
244
+ # MUXR_SESSION + MUXR_CONTROL_SOCKET + MUXR_FOCUSED_PANE in the env so
245
+ # the muxr-mcp bridge inside that claude process auto-attaches to this
246
+ # session.
247
+ def toggle_claude_drawer
248
+ toggle_drawer_kind(command: "claude")
249
+ end
250
+
168
251
  def show_drawer
169
252
  ensure_drawer
170
253
  @session.drawer.show!
@@ -464,6 +547,20 @@ module Muxr
464
547
  end
465
548
  end
466
549
 
550
+ # Live pane rects for the current layout/size, computed the same way the
551
+ # Renderer does so spatial neighbor lookup matches what the user sees.
552
+ def current_pane_rects
553
+ win = @session.window
554
+ area = LayoutManager::Rect.new(0, 0, @session.width, @session.height - 1)
555
+ LayoutManager.compute(
556
+ win.layout,
557
+ win.panes.length,
558
+ area,
559
+ focused_index: win.focused_index,
560
+ master_index: win.master_index
561
+ )
562
+ end
563
+
467
564
  def focused_pane
468
565
  @session.window.focused_pane
469
566
  end
@@ -477,20 +574,32 @@ module Muxr
477
574
  @listening_socket = UNIXServer.new(@socket_path)
478
575
  File.chmod(0o600, @socket_path) rescue nil
479
576
 
577
+ # Sibling control socket — multi-client, NDJSON, used by bin/muxr-mcp
578
+ # and any other programmatic driver. Connected control clients do not
579
+ # count as "attached", so a Claude Code session can poke the muxr
580
+ # server without contending with the human's TTY client.
581
+ @control_server = ControlServer.new(self, @control_socket_path)
582
+ @control_server.start
583
+
480
584
  @session = Session.new(name: @session_name, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
481
585
  @renderer = Renderer.new(out: FramedOutput.new(self))
482
586
  @input = InputHandler.new(self)
483
587
 
484
- first_pane = make_pane
485
- @session.window.add_pane(first_pane)
588
+ saved = Session.load(@session_name)
589
+ first_id = saved && saved.dig("panes", 0, "id")
590
+ @session.window.add_pane(make_pane(id: first_id))
486
591
 
487
- restore_panes_if_saved
592
+ restore_panes_if_saved(saved) if saved
488
593
 
489
594
  @running = true
595
+ start_foreground_poller
490
596
  end
491
597
 
492
598
  def teardown
599
+ stop_foreground_poller
493
600
  disconnect_client
601
+ @control_server&.stop
602
+ @control_server = nil
494
603
  if @listening_socket
495
604
  @listening_socket.close rescue nil
496
605
  end
@@ -508,6 +617,7 @@ module Muxr
508
617
  @session.window.panes.each { |p| read_ios << p.io if p.alive? }
509
618
  drawer_pane = @session.drawer&.pane
510
619
  read_ios << drawer_pane.io if drawer_pane&.alive?
620
+ read_ios.concat(@control_server.read_ios) if @control_server
511
621
 
512
622
  write_ios = []
513
623
  @session.window.panes.each do |p|
@@ -517,6 +627,7 @@ module Muxr
517
627
  write_ios << drawer_pane.writer_io
518
628
  end
519
629
  write_ios << @current_client if @current_client && !@client_write_buffer.empty?
630
+ write_ios.concat(@control_server.write_ios) if @control_server
520
631
 
521
632
  timeout = @message ? 0.25 : SELECT_TIMEOUT
522
633
  # If a render is queued but we're inside the frame-rate budget, wake
@@ -540,6 +651,8 @@ module Muxr
540
651
  accept_client
541
652
  elsif io == @current_client
542
653
  consume_client_frame
654
+ elsif @control_server&.owns?(io)
655
+ @control_server.handle_read(io)
543
656
  else
544
657
  consume_pane_io(io)
545
658
  end
@@ -548,13 +661,18 @@ module Muxr
548
661
  ready_w&.each do |io|
549
662
  if io == @current_client
550
663
  drain_client_writes
664
+ elsif @control_server&.owns?(io)
665
+ @control_server.handle_write(io)
551
666
  else
552
667
  pane = pane_for_writer_io(io)
553
668
  pane&.drain_writes
554
669
  end
555
670
  end
556
671
 
672
+ @control_server&.tick
673
+
557
674
  prune_dead_panes
675
+ prune_dead_drawer
558
676
  expire_message
559
677
 
560
678
  if @session.window.panes.empty?
@@ -647,7 +765,14 @@ module Muxr
647
765
  pane = pane_for_io(io)
648
766
  return unless pane
649
767
  data = pane.read_from_pty
650
- invalidate if data
768
+ if data
769
+ invalidate
770
+ # Notify the control surface so any pending pane.run waiters reset
771
+ # their idle window and any pane.subscribe clients get a new frame.
772
+ # read_from_pty already fed the bytes into the Terminal; the control
773
+ # server pulls the resulting text out of pane.terminal.dump_text.
774
+ @control_server&.on_pane_output(pane.id, data) if pane.id.is_a?(String)
775
+ end
651
776
  end
652
777
 
653
778
  def pane_for_io(io)
@@ -671,6 +796,25 @@ module Muxr
671
796
  invalidate
672
797
  end
673
798
 
799
+ # When the shell (or claude) inside the drawer exits, tear the drawer
800
+ # down so the next Ctrl-a ~ / Ctrl-a C spawns a fresh one. Without this
801
+ # the drawer pane stays mounted around a dead PTY and looks like the
802
+ # multiplexer is wedged.
803
+ def prune_dead_drawer
804
+ drawer = @session.drawer
805
+ return unless drawer
806
+ pane = drawer.pane
807
+ return unless pane
808
+ return if pane.alive?
809
+ kind = drawer.command ? "#{drawer.command} drawer" : "drawer"
810
+ drawer.close
811
+ @session.drawer = nil
812
+ @session.focus_drawer = false
813
+ renderer.reset_frame!
814
+ flash("#{kind} exited")
815
+ invalidate
816
+ end
817
+
674
818
  def expire_message
675
819
  return unless @message_expires
676
820
  if Time.now >= @message_expires
@@ -729,6 +873,55 @@ module Muxr
729
873
  # Fire-and-forget pipe to pbcopy. Runs on its own thread so even a slow
730
874
  # macOS pbcopy doesn't stall the event loop. Silent when pbcopy is absent
731
875
  # (Linux/headless) — selection still goes to the internal buffer.
876
+ # Background thread that walks every pane and writes its foreground
877
+ # command back onto pane.foreground_command. Lives off the event loop
878
+ # because the macOS `ps` path is fork+exec'y; on Linux the procfs reads
879
+ # would be fast enough on the main thread but a single code path is
880
+ # easier to reason about. Atomic pointer writes (MRI GVL) mean we don't
881
+ # need a lock for the renderer's per-frame read.
882
+ def start_foreground_poller
883
+ return if @foreground_poller
884
+ @foreground_poller = Thread.new do
885
+ while @running
886
+ begin
887
+ poll_foreground_commands
888
+ rescue StandardError
889
+ # Never let a poller crash kill the server. If the lookup keeps
890
+ # failing the titles just won't show commands — that's fine.
891
+ end
892
+ sleep FOREGROUND_POLL_INTERVAL
893
+ end
894
+ end
895
+ end
896
+
897
+ def stop_foreground_poller
898
+ thread = @foreground_poller
899
+ @foreground_poller = nil
900
+ return unless thread
901
+ # @running has already been flipped off; the thread exits on its next
902
+ # wake. join with a small timeout so we don't hang teardown if the
903
+ # thread is mid-`ps`.
904
+ thread.join(2.0) || thread.kill
905
+ end
906
+
907
+ def poll_foreground_commands
908
+ # Snapshot so add/remove on the main thread can't trip us mid-iter.
909
+ panes = @session.window.panes.dup
910
+ drawer_pane = @session.drawer&.pane
911
+ panes << drawer_pane if drawer_pane
912
+ changed = false
913
+ panes.each do |pane|
914
+ next unless pane.alive?
915
+ next unless pane.respond_to?(:pid) && pane.pid
916
+ name = ForegroundCommand.lookup(pane.pid)
917
+ if pane.foreground_command != name
918
+ pane.foreground_command = name
919
+ changed = true
920
+ end
921
+ end
922
+ invalidate if changed
923
+ end
924
+
732
925
  def spawn_pbcopy(text)
733
926
  Thread.new do
734
927
  IO.popen("pbcopy", "w") { |io| io.write(text) }
@@ -738,20 +931,62 @@ module Muxr
738
931
  end
739
932
  end
740
933
 
741
- def make_pane(cwd: nil)
742
- @next_pane_id += 1
743
- Pane.new(id: @next_pane_id, rows: 24, cols: 80, cwd: cwd)
934
+ def make_pane(cwd: nil, id: nil)
935
+ Pane.new(id: id, rows: 24, cols: 80, cwd: cwd)
744
936
  end
745
937
 
746
- def ensure_drawer
938
+ def ensure_drawer(command: nil)
747
939
  return if @session.drawer
748
940
  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)
941
+ pane = Pane.new(
942
+ id: :drawer,
943
+ rows: 10,
944
+ cols: 80,
945
+ cwd: cwd,
946
+ command: command,
947
+ env_overrides: drawer_env
948
+ )
949
+ @session.drawer = Drawer.new(pane: pane, origin_cwd: cwd, command: command)
950
+ end
951
+
952
+ # Toggle the drawer; if a different kind is currently up, tear it down
953
+ # and replace it with the requested kind. Keeps the drawer slot a single
954
+ # PTY so users don't end up with a confusing menagerie of overlays.
955
+ def toggle_drawer_kind(command:)
956
+ current = @session.drawer
957
+ if current.nil?
958
+ ensure_drawer(command: command)
959
+ @session.drawer.show!
960
+ @session.focus_drawer = true
961
+ elsif current.command == command
962
+ current.toggle!
963
+ @session.focus_drawer = current.visible?
964
+ else
965
+ current.close
966
+ @session.drawer = nil
967
+ ensure_drawer(command: command)
968
+ @session.drawer.show!
969
+ @session.focus_drawer = true
970
+ end
971
+ renderer.reset_frame!
972
+ invalidate
751
973
  end
752
974
 
753
- def restore_panes_if_saved
754
- data = Session.load(@session_name)
975
+ # Env vars exposed to every drawer PTY. The MCP bridge reads these to
976
+ # auto-connect to the right session; MUXR_DRAWER_SELF lets it refuse
977
+ # drawer.* methods so a claude drawer can't recurse into its own PTY.
978
+ def drawer_env
979
+ env = {
980
+ "MUXR_SESSION" => @session_name.to_s,
981
+ "MUXR_CONTROL_SOCKET" => @control_socket_path.to_s,
982
+ "MUXR_DRAWER_SELF" => "1"
983
+ }
984
+ focused = focused_pane
985
+ env["MUXR_FOCUSED_PANE"] = focused.id.to_s if focused&.id.is_a?(String)
986
+ env
987
+ end
988
+
989
+ def restore_panes_if_saved(data)
755
990
  return unless data
756
991
 
757
992
  if data["layout"] && Window::LAYOUTS.include?(data["layout"].to_sym)
@@ -759,15 +994,30 @@ module Muxr
759
994
  end
760
995
 
761
996
  panes_data = data["panes"] || []
997
+ # Restore privacy flag for the already-created first pane.
998
+ if panes_data[0] && panes_data[0]["private"] && @session.window.panes[0]
999
+ @session.window.panes[0].mark_private!
1000
+ end
762
1001
  panes_data[1..]&.each do |entry|
763
1002
  cwd = entry["cwd"]
764
- @session.window.add_pane(make_pane(cwd: cwd))
1003
+ id = entry["id"]
1004
+ pane = make_pane(cwd: cwd, id: id)
1005
+ pane.mark_private! if entry["private"]
1006
+ @session.window.add_pane(pane)
765
1007
  end
766
1008
 
767
1009
  if data["drawer"]
768
1010
  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)
1011
+ command = data["drawer"]["command"]
1012
+ pane = Pane.new(
1013
+ id: :drawer,
1014
+ rows: 10,
1015
+ cols: 80,
1016
+ cwd: cwd,
1017
+ command: command,
1018
+ env_overrides: drawer_env
1019
+ )
1020
+ drawer = Drawer.new(pane: pane, origin_cwd: cwd, command: command)
771
1021
  drawer.visible = !!data["drawer"]["visible"]
772
1022
  @session.drawer = drawer
773
1023
  @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