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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +229 -68
- data/lib/muxr/application.rb +236 -0
- data/lib/muxr/command_dispatcher.rb +1 -1
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +256 -27
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +10 -0
- data/lib/muxr/renderer.rb +193 -43
- data/lib/muxr/terminal.rb +195 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/window.rb +13 -0
- data/lib/muxr.rb +1 -0
- metadata +2 -1
data/lib/muxr/application.rb
CHANGED
|
@@ -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) }
|
|
@@ -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
|