muxr 0.1.5 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +210 -65
- data/lib/muxr/application.rb +126 -0
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +131 -27
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +10 -0
- data/lib/muxr/renderer.rb +136 -35
- data/lib/muxr/terminal.rb +44 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +1 -0
- metadata +2 -1
|
@@ -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
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -1,17 +1,61 @@
|
|
|
1
1
|
module Muxr
|
|
2
|
-
# Translates raw keystrokes into either commands
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
2
|
+
# Translates raw keystrokes into either commands or pane input. Two top-level
|
|
3
|
+
# modes:
|
|
4
|
+
#
|
|
5
|
+
# :normal Default. Single-key bindings (hjkl navigation, t/g/m for
|
|
6
|
+
# layouts, c/K for create/kill, etc.) act directly without
|
|
7
|
+
# any prefix. `i` drops into passthrough.
|
|
8
|
+
#
|
|
9
|
+
# :passthrough Historical mode: every key is forwarded to the focused
|
|
10
|
+
# pane unless prefixed by Ctrl-a. `Ctrl-a Esc` returns to
|
|
11
|
+
# normal mode.
|
|
12
|
+
#
|
|
13
|
+
# Plus the sub-states pre-existing from before modes existed:
|
|
14
|
+
# :prefix, :command, :confirm_quit, :help, :scrollback, :selection.
|
|
15
|
+
#
|
|
16
|
+
# One-shot sub-states (prefix, command, confirm_quit, help) return to
|
|
17
|
+
# @base_mode (whichever of :normal/:passthrough is active) when they
|
|
18
|
+
# finish. :scrollback and :selection also return to @base_mode so that
|
|
19
|
+
# exiting back from a scroll/yank lands you back in passthrough if that's
|
|
20
|
+
# where you came from.
|
|
6
21
|
class InputHandler
|
|
7
22
|
PREFIX = "\x01".freeze # Ctrl-a
|
|
8
23
|
|
|
24
|
+
# Single-key bindings in normal mode. Same actions as their Ctrl-a-
|
|
25
|
+
# prefixed counterparts in passthrough, just without the prefix.
|
|
26
|
+
# Value forms:
|
|
27
|
+
# :symbol → @app.public_send(:symbol)
|
|
28
|
+
# [:symbol, *args] → @app.public_send(:symbol, *args)
|
|
29
|
+
NORMAL_BINDINGS = {
|
|
30
|
+
"c" => :new_pane,
|
|
31
|
+
"K" => :close_focused,
|
|
32
|
+
"t" => [:set_layout, :tall],
|
|
33
|
+
"g" => [:set_layout, :grid],
|
|
34
|
+
"m" => [:set_layout, :monocle],
|
|
35
|
+
"\t" => :cycle_layout,
|
|
36
|
+
"\r" => :promote_master,
|
|
37
|
+
"\n" => :promote_master,
|
|
38
|
+
"h" => [:focus_direction, :left],
|
|
39
|
+
"j" => [:focus_direction, :down],
|
|
40
|
+
"k" => [:focus_direction, :up],
|
|
41
|
+
"l" => [:focus_direction, :right],
|
|
42
|
+
"a" => :focus_last,
|
|
43
|
+
"~" => :toggle_drawer,
|
|
44
|
+
"C" => :toggle_claude_drawer,
|
|
45
|
+
"P" => :toggle_private_focused,
|
|
46
|
+
"d" => :detach,
|
|
47
|
+
"?" => :show_help,
|
|
48
|
+
"q" => :quit_immediate,
|
|
49
|
+
"s" => :enter_scrollback,
|
|
50
|
+
"]" => :paste_from_buffer
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
9
53
|
PREFIX_BINDINGS = {
|
|
10
54
|
"c" => :new_pane,
|
|
11
55
|
"n" => :focus_next,
|
|
12
56
|
"p" => :focus_prev,
|
|
13
57
|
"a" => :focus_last,
|
|
14
|
-
"
|
|
58
|
+
"K" => :close_focused,
|
|
15
59
|
"\t" => :cycle_layout,
|
|
16
60
|
"\r" => :promote_master,
|
|
17
61
|
"\n" => :promote_master,
|
|
@@ -69,8 +113,10 @@ module Muxr
|
|
|
69
113
|
"u" => :half_up,
|
|
70
114
|
"\x06" => :full_down, # Ctrl-f
|
|
71
115
|
"\x02" => :full_up, # Ctrl-b
|
|
72
|
-
"f" => :full_down
|
|
73
|
-
|
|
116
|
+
"f" => :full_down
|
|
117
|
+
# NOTE: space is intentionally absent here — it's a top-level toggle
|
|
118
|
+
# for linear selection (see handle_selection_input), mirroring vim's
|
|
119
|
+
# `v` so the right thumb has a one-key way to anchor/release.
|
|
74
120
|
}.freeze
|
|
75
121
|
|
|
76
122
|
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
@@ -78,22 +124,22 @@ module Muxr
|
|
|
78
124
|
|
|
79
125
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
80
126
|
|
|
81
|
-
attr_reader :state, :command_buffer
|
|
127
|
+
attr_reader :state, :command_buffer, :base_mode
|
|
82
128
|
|
|
83
129
|
def initialize(app)
|
|
84
130
|
@app = app
|
|
85
|
-
@state = :
|
|
131
|
+
@state = :normal
|
|
132
|
+
@base_mode = :normal
|
|
86
133
|
@command_buffer = +""
|
|
87
134
|
end
|
|
88
135
|
|
|
89
136
|
def feed(data)
|
|
90
137
|
remaining = data
|
|
91
138
|
until remaining.empty?
|
|
92
|
-
if @state == :
|
|
93
|
-
# Fast path
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
# appears mid-UTF-8, so byte/char index match.
|
|
139
|
+
if @state == :passthrough
|
|
140
|
+
# Fast path: batch everything up to the next Ctrl-a as one chunk so
|
|
141
|
+
# a large paste doesn't turn into one PTY write per byte. PREFIX is
|
|
142
|
+
# single-byte ASCII (\x01) and never appears mid-UTF-8.
|
|
97
143
|
idx = remaining.index(PREFIX)
|
|
98
144
|
if idx.nil?
|
|
99
145
|
@app.send_to_focused(remaining)
|
|
@@ -108,9 +154,11 @@ module Muxr
|
|
|
108
154
|
ch = remaining[0]
|
|
109
155
|
remaining = remaining[1..] || ""
|
|
110
156
|
case @state
|
|
157
|
+
when :normal
|
|
158
|
+
handle_normal(ch)
|
|
111
159
|
when :help
|
|
112
160
|
@app.dismiss_help
|
|
113
|
-
@state =
|
|
161
|
+
@state = @base_mode
|
|
114
162
|
when :confirm_quit
|
|
115
163
|
handle_confirm_quit(ch)
|
|
116
164
|
when :prefix
|
|
@@ -141,40 +189,96 @@ module Muxr
|
|
|
141
189
|
@state = :selection
|
|
142
190
|
end
|
|
143
191
|
|
|
192
|
+
# Drop into passthrough — every key reaches the focused pane until the
|
|
193
|
+
# user issues Ctrl-a Esc.
|
|
194
|
+
def enter_passthrough_mode
|
|
195
|
+
@state = :passthrough
|
|
196
|
+
@base_mode = :passthrough
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Return to normal mode. Used by the `Ctrl-a Esc` binding from
|
|
200
|
+
# passthrough — explicitly resets @base_mode so the user genuinely
|
|
201
|
+
# leaves passthrough.
|
|
202
|
+
def enter_normal_mode
|
|
203
|
+
@state = :normal
|
|
204
|
+
@base_mode = :normal
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Exit a sub-state (scrollback, selection-yank) and resume the mode the
|
|
208
|
+
# user was in before they entered scrollback. Preserves @base_mode so
|
|
209
|
+
# a passthrough → scrollback → exit round-trip lands back in passthrough.
|
|
144
210
|
def enter_idle_mode
|
|
145
|
-
@state =
|
|
211
|
+
@state = @base_mode
|
|
146
212
|
end
|
|
147
213
|
|
|
148
214
|
def cancel
|
|
149
|
-
@state =
|
|
215
|
+
@state = @base_mode
|
|
150
216
|
@command_buffer = +""
|
|
151
217
|
end
|
|
152
218
|
|
|
153
219
|
private
|
|
154
220
|
|
|
221
|
+
def handle_normal(ch)
|
|
222
|
+
if ch == "i"
|
|
223
|
+
# Internal state flip happens here so a bare FakeApp in tests still
|
|
224
|
+
# transitions; the Application callback redundantly flips state
|
|
225
|
+
# (idempotent) and adds the user-visible flash.
|
|
226
|
+
enter_passthrough_mode
|
|
227
|
+
@app.enter_passthrough_mode
|
|
228
|
+
return
|
|
229
|
+
end
|
|
230
|
+
if ch == ":"
|
|
231
|
+
@state = :command
|
|
232
|
+
@command_buffer = +""
|
|
233
|
+
return
|
|
234
|
+
end
|
|
235
|
+
if DIGIT_RE.match?(ch)
|
|
236
|
+
@app.focus_pane_number(ch.to_i)
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
action = NORMAL_BINDINGS[ch]
|
|
241
|
+
case action
|
|
242
|
+
when Symbol
|
|
243
|
+
@app.public_send(action)
|
|
244
|
+
when Array
|
|
245
|
+
@app.public_send(*action)
|
|
246
|
+
end
|
|
247
|
+
# Unknown key: ignore. Avoids accidental side-effects when the user
|
|
248
|
+
# mistypes — same rationale as scrollback mode.
|
|
249
|
+
end
|
|
250
|
+
|
|
155
251
|
def handle_prefix(ch)
|
|
156
252
|
action = PREFIX_BINDINGS[ch]
|
|
157
253
|
case
|
|
254
|
+
when ch == "\e"
|
|
255
|
+
# Ctrl-a Esc → return to normal mode. Flip state directly so tests
|
|
256
|
+
# with a bare FakeApp transition; the Application callback is
|
|
257
|
+
# idempotent and adds the flash message.
|
|
258
|
+
enter_normal_mode
|
|
259
|
+
@app.enter_normal_mode
|
|
158
260
|
when ch == ":"
|
|
159
261
|
@state = :command
|
|
160
262
|
@command_buffer = +""
|
|
161
263
|
when ch == PREFIX
|
|
162
264
|
@app.send_to_focused(PREFIX)
|
|
163
|
-
@state =
|
|
265
|
+
@state = @base_mode
|
|
164
266
|
when DIGIT_RE.match?(ch)
|
|
165
267
|
@app.focus_pane_number(ch.to_i)
|
|
166
|
-
@state =
|
|
268
|
+
@state = @base_mode
|
|
167
269
|
when action
|
|
168
270
|
@app.public_send(action)
|
|
169
|
-
|
|
271
|
+
# The action may have set a new state (confirm_quit, scrollback,
|
|
272
|
+
# help). Only revert to base mode if we're still in :prefix.
|
|
273
|
+
@state = @base_mode if @state == :prefix
|
|
170
274
|
else
|
|
171
|
-
# Unknown prefix
|
|
172
|
-
@state =
|
|
275
|
+
# Unknown prefix key: return to base mode silently.
|
|
276
|
+
@state = @base_mode
|
|
173
277
|
end
|
|
174
278
|
end
|
|
175
279
|
|
|
176
280
|
def handle_confirm_quit(ch)
|
|
177
|
-
@state =
|
|
281
|
+
@state = @base_mode
|
|
178
282
|
if ch == "y" || ch == "Y"
|
|
179
283
|
@app.confirm_quit
|
|
180
284
|
else
|
|
@@ -184,7 +288,7 @@ module Muxr
|
|
|
184
288
|
|
|
185
289
|
def handle_scrollback_input(ch)
|
|
186
290
|
if SCROLLBACK_EXITS.include?(ch)
|
|
187
|
-
|
|
291
|
+
enter_idle_mode
|
|
188
292
|
@app.exit_scrollback
|
|
189
293
|
return
|
|
190
294
|
end
|
|
@@ -208,7 +312,7 @@ module Muxr
|
|
|
208
312
|
return
|
|
209
313
|
end
|
|
210
314
|
case ch
|
|
211
|
-
when "v"
|
|
315
|
+
when "v", " "
|
|
212
316
|
@app.toggle_selection(:linear)
|
|
213
317
|
return
|
|
214
318
|
when "\x16" # Ctrl-v
|
|
@@ -225,11 +329,11 @@ module Muxr
|
|
|
225
329
|
when "\r", "\n"
|
|
226
330
|
cmd = @command_buffer.dup
|
|
227
331
|
@command_buffer = +""
|
|
228
|
-
@state =
|
|
332
|
+
@state = @base_mode
|
|
229
333
|
@app.run_command(cmd)
|
|
230
334
|
when "\e"
|
|
231
335
|
@command_buffer = +""
|
|
232
|
-
@state =
|
|
336
|
+
@state = @base_mode
|
|
233
337
|
@app.invalidate
|
|
234
338
|
when "\x7f", "\b"
|
|
235
339
|
@command_buffer.chop!
|
data/lib/muxr/layout_manager.rb
CHANGED
|
@@ -87,5 +87,64 @@ module Muxr
|
|
|
87
87
|
def monocle(count, area, _focused_index = 0)
|
|
88
88
|
Array.new(count) { Rect.new(area.x, area.y, area.w, area.h) }
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
# Return the index of the closest pane in `direction` (:left/:right/:up/:down)
|
|
92
|
+
# from the focused pane. Pure function over the rect list — does not know
|
|
93
|
+
# about the layout that produced the rects.
|
|
94
|
+
#
|
|
95
|
+
# Selection rule: among panes strictly on the requested side, prefer the
|
|
96
|
+
# one with the largest perpendicular overlap with the focused pane;
|
|
97
|
+
# tie-break by smallest axis-distance, then by smallest center offset.
|
|
98
|
+
# Returns nil when nothing qualifies (e.g. focused is the rightmost pane
|
|
99
|
+
# and direction is :right, or monocle where every rect is identical).
|
|
100
|
+
def neighbor(rects, focused_index, direction)
|
|
101
|
+
return nil if rects.nil? || rects.empty?
|
|
102
|
+
return nil unless focused_index.is_a?(Integer)
|
|
103
|
+
return nil unless focused_index.between?(0, rects.length - 1)
|
|
104
|
+
focused = rects[focused_index]
|
|
105
|
+
return nil unless focused
|
|
106
|
+
|
|
107
|
+
best = nil
|
|
108
|
+
rects.each_with_index do |rect, idx|
|
|
109
|
+
next if idx == focused_index || rect.nil?
|
|
110
|
+
|
|
111
|
+
case direction
|
|
112
|
+
when :right
|
|
113
|
+
next unless rect.x >= focused.x + focused.w
|
|
114
|
+
axis_dist = rect.x - (focused.x + focused.w)
|
|
115
|
+
overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
|
|
116
|
+
center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
|
|
117
|
+
when :left
|
|
118
|
+
next unless rect.x + rect.w <= focused.x
|
|
119
|
+
axis_dist = focused.x - (rect.x + rect.w)
|
|
120
|
+
overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
|
|
121
|
+
center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
|
|
122
|
+
when :down
|
|
123
|
+
next unless rect.y >= focused.y + focused.h
|
|
124
|
+
axis_dist = rect.y - (focused.y + focused.h)
|
|
125
|
+
overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
|
|
126
|
+
center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
|
|
127
|
+
when :up
|
|
128
|
+
next unless rect.y + rect.h <= focused.y
|
|
129
|
+
axis_dist = focused.y - (rect.y + rect.h)
|
|
130
|
+
overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
|
|
131
|
+
center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
|
|
132
|
+
else
|
|
133
|
+
return nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
score = [-overlap, axis_dist, center]
|
|
137
|
+
if best.nil? || (score <=> best[0]) < 0
|
|
138
|
+
best = [score, idx]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
best && best[1]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def overlap_extent(a_start, a_size, b_start, b_size)
|
|
145
|
+
finish = [a_start + a_size, b_start + b_size].min
|
|
146
|
+
start = [a_start, b_start].max
|
|
147
|
+
[finish - start, 0].max
|
|
148
|
+
end
|
|
90
149
|
end
|
|
91
150
|
end
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -14,6 +14,11 @@ module Muxr
|
|
|
14
14
|
class Pane
|
|
15
15
|
attr_reader :id, :terminal, :process
|
|
16
16
|
attr_accessor :rect
|
|
17
|
+
# Last value written by Application's foreground poller thread. nil when
|
|
18
|
+
# the shell itself is foreground (the common empty-prompt case) or when
|
|
19
|
+
# the lookup hasn't run / couldn't read. Renderer surfaces this in the
|
|
20
|
+
# pane title.
|
|
21
|
+
attr_accessor :foreground_command
|
|
17
22
|
|
|
18
23
|
def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
|
|
19
24
|
@id = id || SecureRandom.hex(3)
|
|
@@ -30,6 +35,11 @@ module Muxr
|
|
|
30
35
|
@rect = nil
|
|
31
36
|
@initial_cwd = cwd || @process.cwd
|
|
32
37
|
@private_flag = false
|
|
38
|
+
@foreground_command = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pid
|
|
42
|
+
@process.pid
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
# Private panes are invisible to the MCP control surface — their cwd is
|