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.
@@ -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 = focused_pane&.cwd
86
- @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)
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
- def toggle_drawer
154
- ensure_drawer
155
- @session.drawer.toggle!
156
- @session.focus_drawer = @session.drawer.visible?
157
- @session.focus_drawer = false unless @session.drawer.visible?
158
- 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")
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
- first_pane = make_pane
479
- @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))
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
- render
547
- @needs_render = false
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
- 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
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
- @next_pane_id += 1
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(id: :drawer, rows: 10, cols: 80, cwd: cwd)
705
- @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
706
847
  end
707
848
 
708
- def restore_panes_if_saved
709
- 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)
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
- @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)
720
881
  end
721
882
 
722
883
  if data["drawer"]
723
884
  cwd = data["drawer"]["cwd"]
724
- pane = Pane.new(id: :drawer, rows: 10, cols: 80, cwd: cwd)
725
- 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)
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