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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -1
- data/README.md +210 -65
- data/bin/muxr +61 -0
- data/bin/muxr-mcp +412 -0
- data/lib/muxr/application.rb +276 -26
- data/lib/muxr/command_dispatcher.rb +2 -0
- data/lib/muxr/control_server.rb +670 -0
- data/lib/muxr/drawer.rb +9 -2
- data/lib/muxr/foreground_command.rb +86 -0
- data/lib/muxr/input_handler.rb +133 -27
- data/lib/muxr/key_parser.rb +89 -0
- data/lib/muxr/layout_manager.rb +59 -0
- data/lib/muxr/pane.rb +60 -3
- data/lib/muxr/renderer.rb +145 -33
- data/lib/muxr/session.rb +13 -2
- data/lib/muxr/terminal.rb +81 -2
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr.rb +2 -0
- data/muxr.gemspec +3 -1
- data/skills/muxr-control/SKILL.md +190 -0
- metadata +7 -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,21 +1,67 @@
|
|
|
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,
|
|
18
62
|
"~" => :toggle_drawer,
|
|
63
|
+
"C" => :toggle_claude_drawer,
|
|
64
|
+
"P" => :toggle_private_focused,
|
|
19
65
|
"d" => :detach,
|
|
20
66
|
"?" => :show_help,
|
|
21
67
|
"q" => :quit_immediate,
|
|
@@ -67,8 +113,10 @@ module Muxr
|
|
|
67
113
|
"u" => :half_up,
|
|
68
114
|
"\x06" => :full_down, # Ctrl-f
|
|
69
115
|
"\x02" => :full_up, # Ctrl-b
|
|
70
|
-
"f" => :full_down
|
|
71
|
-
|
|
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.
|
|
72
120
|
}.freeze
|
|
73
121
|
|
|
74
122
|
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
@@ -76,22 +124,22 @@ module Muxr
|
|
|
76
124
|
|
|
77
125
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
78
126
|
|
|
79
|
-
attr_reader :state, :command_buffer
|
|
127
|
+
attr_reader :state, :command_buffer, :base_mode
|
|
80
128
|
|
|
81
129
|
def initialize(app)
|
|
82
130
|
@app = app
|
|
83
|
-
@state = :
|
|
131
|
+
@state = :normal
|
|
132
|
+
@base_mode = :normal
|
|
84
133
|
@command_buffer = +""
|
|
85
134
|
end
|
|
86
135
|
|
|
87
136
|
def feed(data)
|
|
88
137
|
remaining = data
|
|
89
138
|
until remaining.empty?
|
|
90
|
-
if @state == :
|
|
91
|
-
# Fast path
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
# 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.
|
|
95
143
|
idx = remaining.index(PREFIX)
|
|
96
144
|
if idx.nil?
|
|
97
145
|
@app.send_to_focused(remaining)
|
|
@@ -106,9 +154,11 @@ module Muxr
|
|
|
106
154
|
ch = remaining[0]
|
|
107
155
|
remaining = remaining[1..] || ""
|
|
108
156
|
case @state
|
|
157
|
+
when :normal
|
|
158
|
+
handle_normal(ch)
|
|
109
159
|
when :help
|
|
110
160
|
@app.dismiss_help
|
|
111
|
-
@state =
|
|
161
|
+
@state = @base_mode
|
|
112
162
|
when :confirm_quit
|
|
113
163
|
handle_confirm_quit(ch)
|
|
114
164
|
when :prefix
|
|
@@ -139,40 +189,96 @@ module Muxr
|
|
|
139
189
|
@state = :selection
|
|
140
190
|
end
|
|
141
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.
|
|
142
210
|
def enter_idle_mode
|
|
143
|
-
@state =
|
|
211
|
+
@state = @base_mode
|
|
144
212
|
end
|
|
145
213
|
|
|
146
214
|
def cancel
|
|
147
|
-
@state =
|
|
215
|
+
@state = @base_mode
|
|
148
216
|
@command_buffer = +""
|
|
149
217
|
end
|
|
150
218
|
|
|
151
219
|
private
|
|
152
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
|
+
|
|
153
251
|
def handle_prefix(ch)
|
|
154
252
|
action = PREFIX_BINDINGS[ch]
|
|
155
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
|
|
156
260
|
when ch == ":"
|
|
157
261
|
@state = :command
|
|
158
262
|
@command_buffer = +""
|
|
159
263
|
when ch == PREFIX
|
|
160
264
|
@app.send_to_focused(PREFIX)
|
|
161
|
-
@state =
|
|
265
|
+
@state = @base_mode
|
|
162
266
|
when DIGIT_RE.match?(ch)
|
|
163
267
|
@app.focus_pane_number(ch.to_i)
|
|
164
|
-
@state =
|
|
268
|
+
@state = @base_mode
|
|
165
269
|
when action
|
|
166
270
|
@app.public_send(action)
|
|
167
|
-
|
|
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
|
|
168
274
|
else
|
|
169
|
-
# Unknown prefix
|
|
170
|
-
@state =
|
|
275
|
+
# Unknown prefix key: return to base mode silently.
|
|
276
|
+
@state = @base_mode
|
|
171
277
|
end
|
|
172
278
|
end
|
|
173
279
|
|
|
174
280
|
def handle_confirm_quit(ch)
|
|
175
|
-
@state =
|
|
281
|
+
@state = @base_mode
|
|
176
282
|
if ch == "y" || ch == "Y"
|
|
177
283
|
@app.confirm_quit
|
|
178
284
|
else
|
|
@@ -182,7 +288,7 @@ module Muxr
|
|
|
182
288
|
|
|
183
289
|
def handle_scrollback_input(ch)
|
|
184
290
|
if SCROLLBACK_EXITS.include?(ch)
|
|
185
|
-
|
|
291
|
+
enter_idle_mode
|
|
186
292
|
@app.exit_scrollback
|
|
187
293
|
return
|
|
188
294
|
end
|
|
@@ -206,7 +312,7 @@ module Muxr
|
|
|
206
312
|
return
|
|
207
313
|
end
|
|
208
314
|
case ch
|
|
209
|
-
when "v"
|
|
315
|
+
when "v", " "
|
|
210
316
|
@app.toggle_selection(:linear)
|
|
211
317
|
return
|
|
212
318
|
when "\x16" # Ctrl-v
|
|
@@ -223,11 +329,11 @@ module Muxr
|
|
|
223
329
|
when "\r", "\n"
|
|
224
330
|
cmd = @command_buffer.dup
|
|
225
331
|
@command_buffer = +""
|
|
226
|
-
@state =
|
|
332
|
+
@state = @base_mode
|
|
227
333
|
@app.run_command(cmd)
|
|
228
334
|
when "\e"
|
|
229
335
|
@command_buffer = +""
|
|
230
|
-
@state =
|
|
336
|
+
@state = @base_mode
|
|
231
337
|
@app.invalidate
|
|
232
338
|
when "\x7f", "\b"
|
|
233
339
|
@command_buffer.chop!
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
# Translates an array of "keys" entries (a mix of literal text and vim-style
|
|
3
|
+
# named keys like "<esc>") into a stream of byte segments tagged by kind.
|
|
4
|
+
#
|
|
5
|
+
# Each input element is either:
|
|
6
|
+
#
|
|
7
|
+
# - Literal text (UTF-8) — emitted as [:literal, bytes].
|
|
8
|
+
# - A named key wrapped in angle brackets like "<esc>", "<c-c>", "<up>" —
|
|
9
|
+
# emitted as [:special, bytes] using the appropriate control sequence.
|
|
10
|
+
#
|
|
11
|
+
# The tagged form lets callers wrap *only* literal segments in
|
|
12
|
+
# bracketed-paste markers when desired. Concatenating the bytes of all
|
|
13
|
+
# segments gives the raw byte stream to write to the PTY.
|
|
14
|
+
module KeyParser
|
|
15
|
+
NAMED_KEY_RE = /\A<([^<>]+)>\z/.freeze
|
|
16
|
+
|
|
17
|
+
# CSI = ESC [ , SS3 = ESC O — both standard xterm key encodings.
|
|
18
|
+
CSI = "\e[".b.freeze
|
|
19
|
+
SS3 = "\eO".b.freeze
|
|
20
|
+
|
|
21
|
+
# Single canonical map. Keys are lowercased; aliases live alongside their
|
|
22
|
+
# canonical forms. Values are ASCII byte strings.
|
|
23
|
+
NAMED_KEYS = {
|
|
24
|
+
"esc" => "\e".b,
|
|
25
|
+
"escape" => "\e".b,
|
|
26
|
+
"enter" => "\r".b,
|
|
27
|
+
"cr" => "\r".b,
|
|
28
|
+
"return" => "\r".b,
|
|
29
|
+
"tab" => "\t".b,
|
|
30
|
+
"s-tab" => "#{CSI}Z".b,
|
|
31
|
+
"bs" => "\x7f".b, # what real backspace keys send through a PTY
|
|
32
|
+
"backspace"=> "\x7f".b,
|
|
33
|
+
"space" => " ".b,
|
|
34
|
+
"up" => "#{CSI}A".b,
|
|
35
|
+
"down" => "#{CSI}B".b,
|
|
36
|
+
"right" => "#{CSI}C".b,
|
|
37
|
+
"left" => "#{CSI}D".b,
|
|
38
|
+
"home" => "#{CSI}H".b,
|
|
39
|
+
"end" => "#{CSI}F".b,
|
|
40
|
+
"pageup" => "#{CSI}5~".b,
|
|
41
|
+
"pagedown" => "#{CSI}6~".b,
|
|
42
|
+
"f1" => "#{SS3}P".b,
|
|
43
|
+
"f2" => "#{SS3}Q".b,
|
|
44
|
+
"f3" => "#{SS3}R".b,
|
|
45
|
+
"f4" => "#{SS3}S".b,
|
|
46
|
+
"f5" => "#{CSI}15~".b,
|
|
47
|
+
"f6" => "#{CSI}17~".b,
|
|
48
|
+
"f7" => "#{CSI}18~".b,
|
|
49
|
+
"f8" => "#{CSI}19~".b,
|
|
50
|
+
"f9" => "#{CSI}20~".b,
|
|
51
|
+
"f10" => "#{CSI}21~".b,
|
|
52
|
+
"f11" => "#{CSI}23~".b,
|
|
53
|
+
"f12" => "#{CSI}24~".b
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
CTRL_RE = /\Ac-([a-z])\z/.freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# Returns an array of [kind, bytes] pairs. Raises Dispatcher::Error on a
|
|
61
|
+
# non-array input or an unrecognized `<name>`.
|
|
62
|
+
def translate(entries)
|
|
63
|
+
raise Dispatcher::Error.new("`keys` must be an array of strings") unless entries.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
entries.map do |entry|
|
|
66
|
+
raise Dispatcher::Error.new("`keys` entries must be strings") unless entry.is_a?(String)
|
|
67
|
+
classify(entry)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def classify(entry)
|
|
72
|
+
m = NAMED_KEY_RE.match(entry)
|
|
73
|
+
return [:literal, entry.b] unless m
|
|
74
|
+
|
|
75
|
+
name = m[1].downcase
|
|
76
|
+
bytes = NAMED_KEYS[name] || ctrl_bytes(name)
|
|
77
|
+
raise Dispatcher::Error.new("unknown named key #{entry.inspect}") unless bytes
|
|
78
|
+
|
|
79
|
+
[:special, bytes]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ctrl_bytes(name)
|
|
83
|
+
m = CTRL_RE.match(name)
|
|
84
|
+
return nil unless m
|
|
85
|
+
# <c-a>=0x01 .. <c-z>=0x1A
|
|
86
|
+
(m[1].ord - "a".ord + 1).chr.b
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
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
|
@@ -1,19 +1,66 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
1
3
|
module Muxr
|
|
2
4
|
# A Pane bundles a Terminal emulator buffer with the PTYProcess running the
|
|
3
5
|
# shell that feeds it. The Window keeps a list of panes; the Renderer asks
|
|
4
6
|
# each pane for its current grid contents and cursor position.
|
|
7
|
+
#
|
|
8
|
+
# Each pane has a stable 6-hex id generated at creation. The id survives
|
|
9
|
+
# promote_to_master and other array reshuffles (since it lives on the Pane,
|
|
10
|
+
# not on the index) and is persisted in the session JSON so it also survives
|
|
11
|
+
# a full cold restart. The drawer pane uses the symbol `:drawer` as its id;
|
|
12
|
+
# the MCP control surface treats it specially and never lists it under
|
|
13
|
+
# `panes.list`.
|
|
5
14
|
class Pane
|
|
6
15
|
attr_reader :id, :terminal, :process
|
|
7
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
|
|
8
22
|
|
|
9
|
-
def initialize(id
|
|
10
|
-
@id = id
|
|
23
|
+
def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
|
|
24
|
+
@id = id || SecureRandom.hex(3)
|
|
11
25
|
@rows = rows
|
|
12
26
|
@cols = cols
|
|
13
27
|
@terminal = Terminal.new(rows: rows, cols: cols)
|
|
14
|
-
@process = process || PTYProcess.new(
|
|
28
|
+
@process = process || PTYProcess.new(
|
|
29
|
+
rows: rows,
|
|
30
|
+
cols: cols,
|
|
31
|
+
cwd: cwd,
|
|
32
|
+
command: command,
|
|
33
|
+
env_overrides: env_overrides || {}
|
|
34
|
+
)
|
|
15
35
|
@rect = nil
|
|
16
36
|
@initial_cwd = cwd || @process.cwd
|
|
37
|
+
@private_flag = false
|
|
38
|
+
@foreground_command = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pid
|
|
42
|
+
@process.pid
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Private panes are invisible to the MCP control surface — their cwd is
|
|
46
|
+
# stripped from panes.list and pane.read/send_input/run/subscribe/kill
|
|
47
|
+
# all refuse. Toggled by the human via Ctrl-a P or `:private`. Never
|
|
48
|
+
# settable from the control surface itself (so a misbehaving MCP client
|
|
49
|
+
# can't unmark a pane it shouldn't see).
|
|
50
|
+
def private?
|
|
51
|
+
@private_flag
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_private!
|
|
55
|
+
@private_flag = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mark_public!
|
|
59
|
+
@private_flag = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def toggle_private!
|
|
63
|
+
@private_flag = !@private_flag
|
|
17
64
|
end
|
|
18
65
|
|
|
19
66
|
def io
|
|
@@ -51,6 +98,16 @@ module Muxr
|
|
|
51
98
|
@terminal.feed(chunk)
|
|
52
99
|
total += chunk.bytesize
|
|
53
100
|
end
|
|
101
|
+
# The emulator may owe the inner program a reply (DSR / CPR — see
|
|
102
|
+
# Terminal#take_pending_replies!). Ship it back through the PTY's
|
|
103
|
+
# input side as if it had been typed. Failure here is non-fatal: the
|
|
104
|
+
# process can have exited between read and write.
|
|
105
|
+
if (reply = @terminal.take_pending_replies!)
|
|
106
|
+
begin
|
|
107
|
+
@process.write(reply)
|
|
108
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
109
|
+
end
|
|
110
|
+
end
|
|
54
111
|
total.positive? ? total : nil
|
|
55
112
|
end
|
|
56
113
|
|