muxr 0.1.5 → 0.1.7

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.
@@ -72,8 +72,15 @@ module Muxr
72
72
  @control_server = nil
73
73
  @paste_buffer = +""
74
74
  @last_render_at = nil
75
+ @foreground_poller = nil
75
76
  end
76
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
+
77
84
  attr_reader :paste_buffer
78
85
 
79
86
  def run
@@ -141,6 +148,116 @@ module Muxr
141
148
  invalidate
142
149
  end
143
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
+ # Swap the focused pane with its spatial neighbor in `direction`. Bound
181
+ # to shift-HJKL in normal mode. Mirrors focus_direction's geometry-aware
182
+ # lookup so the same "what does my arrow point at" intuition decides
183
+ # which neighbor gets bumped. Monocle has no spatial layout, so HJKL
184
+ # falls back to reordering by linear next/prev — useful for shuffling
185
+ # the master before flipping back to tall/grid.
186
+ def move_direction(direction)
187
+ return if @session.window.panes.empty?
188
+ # The drawer isn't part of the tiled pane list; HJKL while focused on
189
+ # it would be ambiguous. No-op.
190
+ return if @session.focus_drawer && @session.drawer&.visible?
191
+
192
+ win = @session.window
193
+ idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
194
+ if idx.nil? && win.layout == :monocle
195
+ target = case direction
196
+ when :right, :down then (win.focused_index + 1) % win.panes.length
197
+ when :left, :up then (win.focused_index - 1) % win.panes.length
198
+ end
199
+ if target && target != win.focused_index
200
+ win.move_focused_to(target)
201
+ invalidate
202
+ end
203
+ return
204
+ end
205
+
206
+ return unless idx
207
+ win.move_focused_to(idx)
208
+ invalidate
209
+ end
210
+
211
+ # Explicit layout set, used by the normal-mode t/g/m bindings and the
212
+ # `:layout <name>` command.
213
+ def set_layout(layout)
214
+ @session.window.set_layout(layout)
215
+ flash("layout: #{@session.window.layout}")
216
+ invalidate
217
+ rescue ArgumentError => e
218
+ flash(e.message)
219
+ end
220
+
221
+ # Bound to `i` in normal mode — drops the user into the historical
222
+ # Ctrl-a-prefixed multiplexer mode.
223
+ def enter_passthrough_mode
224
+ @input.enter_passthrough_mode
225
+ flash("passthrough mode (^a esc to return)")
226
+ invalidate
227
+ end
228
+
229
+ # Bound to `Ctrl-a Esc` from passthrough — return to normal mode.
230
+ def enter_normal_mode
231
+ @input.enter_normal_mode
232
+ flash("normal mode")
233
+ invalidate
234
+ end
235
+
236
+ # Two-step close — same shape as the quit flow. Hiding the drawer is
237
+ # cheap and reversible, so we skip the prompt for the drawer case.
238
+ def request_close
239
+ if @session.focus_drawer && @session.drawer&.visible?
240
+ hide_drawer
241
+ return
242
+ end
243
+ return unless focused_pane
244
+ return if @input.state == :confirm_close
245
+ @input.enter_confirm_close
246
+ flash("close pane? (y/n)")
247
+ invalidate
248
+ end
249
+
250
+ def confirm_close
251
+ close_focused
252
+ end
253
+
254
+ def cancel_close
255
+ @message = nil
256
+ @message_expires = nil
257
+ flash("cancelled")
258
+ invalidate
259
+ end
260
+
144
261
  def close_focused
145
262
  if @session.focus_drawer && @session.drawer&.visible?
146
263
  hide_drawer
@@ -276,11 +393,63 @@ module Muxr
276
393
  def exit_scrollback
277
394
  target = focused_target
278
395
  target&.terminal&.clear_selection
396
+ target&.terminal&.clear_search
279
397
  target&.terminal&.scroll_to_bottom
280
398
  @renderer.reset_frame!
281
399
  invalidate
282
400
  end
283
401
 
402
+ # Bound to `/` (forward) and `?` (backward) in scrollback mode. Drops
403
+ # the user into a buffered prompt; commit_search / cancel_search exit
404
+ # back to scrollback.
405
+ def enter_search(direction: :forward)
406
+ @input.enter_search_mode(direction: direction)
407
+ invalidate
408
+ end
409
+
410
+ def commit_search(query)
411
+ target = focused_target
412
+ return unless target
413
+ term = target.terminal
414
+ direction = @input.search_direction
415
+ count = term.search(query, direction: direction)
416
+ if query.empty?
417
+ # Empty query just dismisses the prompt; leave the prior search
418
+ # state alone (term.search already cleared it though).
419
+ elsif count.zero?
420
+ flash("not found: #{query}")
421
+ else
422
+ flash("#{count} match#{count == 1 ? "" : "es"} (n/N to navigate)")
423
+ end
424
+ @renderer.reset_frame!
425
+ invalidate
426
+ end
427
+
428
+ def cancel_search
429
+ @renderer.reset_frame!
430
+ invalidate
431
+ end
432
+
433
+ def find_next
434
+ step_search(@input.search_direction)
435
+ end
436
+
437
+ def find_prev
438
+ step_search(@input.search_direction == :forward ? :backward : :forward)
439
+ end
440
+
441
+ def step_search(direction)
442
+ target = focused_target
443
+ return unless target
444
+ term = target.terminal
445
+ if term.search_matches.empty?
446
+ flash("no search active")
447
+ return
448
+ end
449
+ term.find_in_direction(direction)
450
+ invalidate
451
+ end
452
+
284
453
  def scroll_focused(action)
285
454
  target = focused_target
286
455
  return unless target
@@ -486,6 +655,20 @@ module Muxr
486
655
  end
487
656
  end
488
657
 
658
+ # Live pane rects for the current layout/size, computed the same way the
659
+ # Renderer does so spatial neighbor lookup matches what the user sees.
660
+ def current_pane_rects
661
+ win = @session.window
662
+ area = LayoutManager::Rect.new(0, 0, @session.width, @session.height - 1)
663
+ LayoutManager.compute(
664
+ win.layout,
665
+ win.panes.length,
666
+ area,
667
+ focused_index: win.focused_index,
668
+ master_index: win.master_index
669
+ )
670
+ end
671
+
489
672
  def focused_pane
490
673
  @session.window.focused_pane
491
674
  end
@@ -517,9 +700,11 @@ module Muxr
517
700
  restore_panes_if_saved(saved) if saved
518
701
 
519
702
  @running = true
703
+ start_foreground_poller
520
704
  end
521
705
 
522
706
  def teardown
707
+ stop_foreground_poller
523
708
  disconnect_client
524
709
  @control_server&.stop
525
710
  @control_server = nil
@@ -757,6 +942,8 @@ module Muxr
757
942
  @session,
758
943
  input_state: @input.state,
759
944
  command_buffer: @input.command_buffer,
945
+ search_buffer: @input.search_buffer,
946
+ search_direction: @input.search_direction,
760
947
  message: @message,
761
948
  help: @help_visible
762
949
  )
@@ -796,6 +983,55 @@ module Muxr
796
983
  # Fire-and-forget pipe to pbcopy. Runs on its own thread so even a slow
797
984
  # macOS pbcopy doesn't stall the event loop. Silent when pbcopy is absent
798
985
  # (Linux/headless) — selection still goes to the internal buffer.
986
+ # Background thread that walks every pane and writes its foreground
987
+ # command back onto pane.foreground_command. Lives off the event loop
988
+ # because the macOS `ps` path is fork+exec'y; on Linux the procfs reads
989
+ # would be fast enough on the main thread but a single code path is
990
+ # easier to reason about. Atomic pointer writes (MRI GVL) mean we don't
991
+ # need a lock for the renderer's per-frame read.
992
+ def start_foreground_poller
993
+ return if @foreground_poller
994
+ @foreground_poller = Thread.new do
995
+ while @running
996
+ begin
997
+ poll_foreground_commands
998
+ rescue StandardError
999
+ # Never let a poller crash kill the server. If the lookup keeps
1000
+ # failing the titles just won't show commands — that's fine.
1001
+ end
1002
+ sleep FOREGROUND_POLL_INTERVAL
1003
+ end
1004
+ end
1005
+ end
1006
+
1007
+ def stop_foreground_poller
1008
+ thread = @foreground_poller
1009
+ @foreground_poller = nil
1010
+ return unless thread
1011
+ # @running has already been flipped off; the thread exits on its next
1012
+ # wake. join with a small timeout so we don't hang teardown if the
1013
+ # thread is mid-`ps`.
1014
+ thread.join(2.0) || thread.kill
1015
+ end
1016
+
1017
+ def poll_foreground_commands
1018
+ # Snapshot so add/remove on the main thread can't trip us mid-iter.
1019
+ panes = @session.window.panes.dup
1020
+ drawer_pane = @session.drawer&.pane
1021
+ panes << drawer_pane if drawer_pane
1022
+ changed = false
1023
+ panes.each do |pane|
1024
+ next unless pane.alive?
1025
+ next unless pane.respond_to?(:pid) && pane.pid
1026
+ name = ForegroundCommand.lookup(pane.pid)
1027
+ if pane.foreground_command != name
1028
+ pane.foreground_command = name
1029
+ changed = true
1030
+ end
1031
+ end
1032
+ invalidate if changed
1033
+ end
1034
+
799
1035
  def spawn_pbcopy(text)
800
1036
  Thread.new do
801
1037
  IO.popen("pbcopy", "w") { |io| io.write(text) }
@@ -26,7 +26,7 @@ module Muxr
26
26
  when "new", "c"
27
27
  @app.new_pane
28
28
  when "close", "kill", "k"
29
- @app.close_focused
29
+ @app.request_close
30
30
  when "next" then @app.focus_next
31
31
  when "prev" then @app.focus_prev
32
32
  when "master" then @app.promote_master
@@ -0,0 +1,86 @@
1
+ module Muxr
2
+ # Looks up the foreground command running inside a PTY by walking from the
3
+ # shell's pid → its tpgid (foreground process group on the controlling tty)
4
+ # → that process's command name. Hides the result when the shell itself is
5
+ # foreground so titles aren't full of "bash" / "zsh" noise.
6
+ #
7
+ # Two platform paths:
8
+ # Linux: /proc/<pid>/stat (no fork — runs fast even on the main thread,
9
+ # though Application uses a background thread anyway)
10
+ # macOS: ps -o tpgid=,pgid= -p <pid> + ps -o comm= -p <tpgid>. Two
11
+ # fork+execs, ~10–20ms total — exactly the reason callers run
12
+ # this off the event-loop thread.
13
+ #
14
+ # Returns the command name string or nil. nil also covers "couldn't read"
15
+ # so callers degrade silently rather than risk showing stale data.
16
+ module ForegroundCommand
17
+ # Command names we never want to surface — these are the empty-prompt
18
+ # case. If a user genuinely runs `bash` inside `bash` we'll under-report
19
+ # rather than mis-report.
20
+ SHELLS = %w[bash zsh fish sh dash ksh tcsh csh].freeze
21
+
22
+ module_function
23
+
24
+ def lookup(pid)
25
+ return nil unless pid.is_a?(Integer) && pid > 0
26
+ tpgid, pgid =
27
+ if File.exist?("/proc/#{pid}/stat")
28
+ linux_tpgid(pid)
29
+ else
30
+ macos_tpgid(pid)
31
+ end
32
+ return nil unless tpgid && pgid
33
+ return nil if tpgid <= 0
34
+ return nil if tpgid == pgid # shell is its own foreground — empty prompt
35
+
36
+ name =
37
+ if File.exist?("/proc/#{tpgid}/comm")
38
+ File.read("/proc/#{tpgid}/comm").strip
39
+ else
40
+ `ps -o comm= -p #{tpgid} 2>/dev/null`.strip
41
+ end
42
+ normalize(name)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # Public for testing — strips path/dash/whitespace and filters shells.
48
+ def normalize(name)
49
+ return nil if name.nil? || name.empty?
50
+ name = name.strip
51
+ name = name.sub(/\A-/, "") # login shells appear as "-bash"
52
+ name = File.basename(name)
53
+ return nil if name.empty?
54
+ return nil if SHELLS.include?(name)
55
+ name
56
+ end
57
+
58
+ # Public for testing — parses Linux /proc/<pid>/stat into [tpgid, pgid].
59
+ # The comm field can contain spaces and parens, so we slice from the
60
+ # last ')' rather than splitting from the start.
61
+ def parse_linux_stat(raw)
62
+ idx = raw.rindex(")")
63
+ return [nil, nil] unless idx
64
+ tail = raw[(idx + 2)..]
65
+ return [nil, nil] unless tail
66
+ fields = tail.split(" ")
67
+ # After the closing paren the fields are:
68
+ # state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) ...
69
+ pgid = fields[2]&.to_i
70
+ tpgid = fields[5]&.to_i
71
+ [tpgid, pgid]
72
+ end
73
+
74
+ def linux_tpgid(pid)
75
+ raw = File.read("/proc/#{pid}/stat")
76
+ parse_linux_stat(raw)
77
+ end
78
+
79
+ def macos_tpgid(pid)
80
+ out = `ps -o tpgid=,pgid= -p #{pid} 2>/dev/null`.strip
81
+ return [nil, nil] if out.empty?
82
+ tpgid, pgid = out.split.map(&:to_i)
83
+ [tpgid, pgid]
84
+ end
85
+ end
86
+ end