muxr 0.1.11 → 0.2.0
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 +225 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +516 -26
- data/lib/muxr/client.rb +26 -4
- data/lib/muxr/command_dispatcher.rb +69 -0
- data/lib/muxr/config.rb +156 -0
- data/lib/muxr/control_server.rb +301 -3
- data/lib/muxr/history_row.rb +264 -0
- data/lib/muxr/image_store.rb +61 -0
- data/lib/muxr/input_handler.rb +112 -14
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +122 -2
- data/lib/muxr/pane_picker.rb +49 -0
- data/lib/muxr/pane_transfer.rb +172 -0
- data/lib/muxr/protocol.rb +51 -5
- data/lib/muxr/pty_process.rb +34 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +259 -76
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +759 -82
- data/lib/muxr/version.rb +1 -1
- data/lib/muxr/width_probe.rb +121 -0
- data/lib/muxr/window.rb +50 -3
- data/lib/muxr.rb +8 -0
- data/muxr.gemspec +10 -0
- data/skills/muxr-control/SKILL.md +158 -8
- metadata +18 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
module Muxr
|
|
2
|
+
class HistoryRow
|
|
3
|
+
include Enumerable
|
|
4
|
+
|
|
5
|
+
CACHE_LIMIT = 256
|
|
6
|
+
NO_CELLS = [].freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def pack(cells, last)
|
|
10
|
+
return EMPTY if last.nil?
|
|
11
|
+
new(cells, last + 1)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def retain(row)
|
|
15
|
+
@retained[row] = true
|
|
16
|
+
evict_oldest while @retained.size > CACHE_LIMIT
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def evict_oldest
|
|
22
|
+
oldest, = @retained.first
|
|
23
|
+
@retained.delete(oldest)
|
|
24
|
+
oldest.release!
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
@retained = {}
|
|
28
|
+
|
|
29
|
+
def initialize(cells, count)
|
|
30
|
+
@count = count
|
|
31
|
+
@cells = count.zero? ? NO_CELLS : nil
|
|
32
|
+
pack_from(cells, count)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def length
|
|
36
|
+
@count
|
|
37
|
+
end
|
|
38
|
+
alias size length
|
|
39
|
+
|
|
40
|
+
def empty?
|
|
41
|
+
@count.zero?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def [](c)
|
|
45
|
+
return nil if c >= @count
|
|
46
|
+
(@cells || materialize)[c]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def each(&block)
|
|
50
|
+
return to_enum(:each) unless block
|
|
51
|
+
(@cells || materialize).each(&block)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def search_text(cols)
|
|
56
|
+
return padded_text(cols), nil unless @lengths
|
|
57
|
+
line = String.new(capacity: @text.bytesize + cols)
|
|
58
|
+
col_at = []
|
|
59
|
+
limit = [@count, cols].min
|
|
60
|
+
each_char_cell do |c, char|
|
|
61
|
+
break if c >= limit
|
|
62
|
+
next if char.empty?
|
|
63
|
+
char.each_char { col_at << c }
|
|
64
|
+
line << char
|
|
65
|
+
end
|
|
66
|
+
(limit...cols).each do |c|
|
|
67
|
+
line << Terminal::BLANK_CHAR
|
|
68
|
+
col_at << c
|
|
69
|
+
end
|
|
70
|
+
[line, col_at]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def to_ansi
|
|
74
|
+
last = nil
|
|
75
|
+
each_styled_cell { |c, char, fg, bg, attrs, link| last = c if significant?(char, fg, bg, attrs, link) }
|
|
76
|
+
return "" if last.nil?
|
|
77
|
+
out = +""
|
|
78
|
+
sgr = nil
|
|
79
|
+
link_open = nil
|
|
80
|
+
pen = nil
|
|
81
|
+
skip = 0
|
|
82
|
+
each_styled_cell do |c, char, fg, bg, attrs, link|
|
|
83
|
+
break if c > last
|
|
84
|
+
if skip.positive?
|
|
85
|
+
skip -= 1
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
unless pen && pen[0] == fg && pen[1] == bg && pen[2] == attrs
|
|
89
|
+
if (s = Terminal.sgr_for(fg, bg, attrs)) != sgr
|
|
90
|
+
out << s
|
|
91
|
+
sgr = s
|
|
92
|
+
end
|
|
93
|
+
pen = [fg, bg, attrs]
|
|
94
|
+
end
|
|
95
|
+
if link != link_open
|
|
96
|
+
out << "\e]8;;\e\\" if link_open
|
|
97
|
+
out << "\e]#{link}\e\\" if link
|
|
98
|
+
link_open = link
|
|
99
|
+
end
|
|
100
|
+
if char.empty?
|
|
101
|
+
out << Terminal::BLANK_CHAR
|
|
102
|
+
else
|
|
103
|
+
out << char
|
|
104
|
+
skip = 1 if Terminal.char_width(char.codepoints.first) == 2
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
out << "\e]8;;\e\\" if link_open
|
|
108
|
+
out
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def repack!
|
|
112
|
+
return if @count.zero? || @cells.nil?
|
|
113
|
+
pack_from(@cells, @count)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def release!
|
|
117
|
+
@cells = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def pack_from(cells, count)
|
|
123
|
+
text = String.new(capacity: count)
|
|
124
|
+
lengths = nil
|
|
125
|
+
runs = nil
|
|
126
|
+
run_len = 0
|
|
127
|
+
fg = nil
|
|
128
|
+
bg = nil
|
|
129
|
+
attrs = 0
|
|
130
|
+
link = nil
|
|
131
|
+
i = 0
|
|
132
|
+
while i < count
|
|
133
|
+
cell = cells[i]
|
|
134
|
+
ch = cell ? cell.char : Terminal::BLANK_CHAR
|
|
135
|
+
n = ch.length
|
|
136
|
+
lengths ||= Array.new(i, 1) unless n == 1
|
|
137
|
+
lengths << n if lengths
|
|
138
|
+
text << ch
|
|
139
|
+
cell_fg = cell ? cell.fg : nil
|
|
140
|
+
cell_bg = cell ? cell.bg : nil
|
|
141
|
+
cell_attrs = cell ? cell.attrs.to_i : 0
|
|
142
|
+
cell_link = cell ? cell.hyperlink : nil
|
|
143
|
+
if run_len.positive? && cell_attrs == attrs && cell_fg == fg &&
|
|
144
|
+
cell_bg == bg && cell_link == link
|
|
145
|
+
run_len += 1
|
|
146
|
+
else
|
|
147
|
+
runs = push_run(runs, run_len, fg, bg, attrs, link)
|
|
148
|
+
run_len = 1
|
|
149
|
+
fg = cell_fg
|
|
150
|
+
bg = cell_bg
|
|
151
|
+
attrs = cell_attrs
|
|
152
|
+
link = cell_link
|
|
153
|
+
end
|
|
154
|
+
i += 1
|
|
155
|
+
end
|
|
156
|
+
runs = push_run(runs, run_len, fg, bg, attrs, link)
|
|
157
|
+
@text = text.freeze
|
|
158
|
+
@lengths = lengths
|
|
159
|
+
@runs = unstyled?(runs) ? nil : runs
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def push_run(runs, len, fg, bg, attrs, link)
|
|
163
|
+
return runs unless len.positive?
|
|
164
|
+
(runs || []).push(len, fg, bg, attrs, link)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def unstyled?(runs)
|
|
168
|
+
runs.nil? ||
|
|
169
|
+
(runs.length == 5 && runs[1].nil? && runs[2].nil? && runs[3].zero? && runs[4].nil?)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def padded_text(cols)
|
|
173
|
+
pad = cols - @count
|
|
174
|
+
return @text if pad.zero?
|
|
175
|
+
return @text[0, cols] if pad.negative?
|
|
176
|
+
@text + (Terminal::BLANK_CHAR * pad)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def significant?(char, fg, bg, attrs, link)
|
|
180
|
+
return true if fg || bg || link || !attrs.zero?
|
|
181
|
+
char != Terminal::BLANK_CHAR && !char.empty?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def each_char_cell
|
|
185
|
+
if @lengths
|
|
186
|
+
chars = @text.chars
|
|
187
|
+
pos = 0
|
|
188
|
+
@count.times do |i|
|
|
189
|
+
n = @lengths[i]
|
|
190
|
+
ch = case n
|
|
191
|
+
when 0 then Terminal::CONTINUATION_CHAR
|
|
192
|
+
when 1 then Terminal.intern_char(chars[pos])
|
|
193
|
+
else chars[pos, n].join
|
|
194
|
+
end
|
|
195
|
+
pos += n
|
|
196
|
+
yield i, ch
|
|
197
|
+
end
|
|
198
|
+
elsif @text.ascii_only?
|
|
199
|
+
@count.times { |i| yield i, Terminal::ASCII_CHARS[@text.getbyte(i)] }
|
|
200
|
+
else
|
|
201
|
+
i = 0
|
|
202
|
+
@text.each_char do |ch|
|
|
203
|
+
yield i, Terminal.intern_char(ch)
|
|
204
|
+
i += 1
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def each_styled_cell
|
|
210
|
+
runs = @runs
|
|
211
|
+
j = 0
|
|
212
|
+
left = runs ? runs[0] : @count
|
|
213
|
+
each_char_cell do |c, char|
|
|
214
|
+
while left.zero? && runs && j + 5 < runs.length
|
|
215
|
+
j += 5
|
|
216
|
+
left = runs[j]
|
|
217
|
+
end
|
|
218
|
+
left -= 1
|
|
219
|
+
if runs
|
|
220
|
+
yield c, char, runs[j + 1], runs[j + 2], runs[j + 3], runs[j + 4]
|
|
221
|
+
else
|
|
222
|
+
yield c, char, nil, nil, 0, nil
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def materialize
|
|
228
|
+
cells = Array.new(@count)
|
|
229
|
+
each_char_cell { |i, ch| cells[i] = Terminal::Cell.new(ch, nil, nil, 0, nil) }
|
|
230
|
+
apply_runs(cells)
|
|
231
|
+
@cells = cells
|
|
232
|
+
self.class.retain(self)
|
|
233
|
+
cells
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def apply_runs(cells)
|
|
237
|
+
runs = @runs
|
|
238
|
+
return if runs.nil?
|
|
239
|
+
col = 0
|
|
240
|
+
j = 0
|
|
241
|
+
while j < runs.length
|
|
242
|
+
len = runs[j]
|
|
243
|
+
fg = runs[j + 1]
|
|
244
|
+
bg = runs[j + 2]
|
|
245
|
+
attrs = runs[j + 3]
|
|
246
|
+
link = runs[j + 4]
|
|
247
|
+
if fg || bg || link || !attrs.zero?
|
|
248
|
+
len.times do |k|
|
|
249
|
+
cell = cells[col + k]
|
|
250
|
+
next unless cell
|
|
251
|
+
cell.fg = fg
|
|
252
|
+
cell.bg = bg
|
|
253
|
+
cell.attrs = attrs
|
|
254
|
+
cell.hyperlink = link
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
col += len
|
|
258
|
+
j += 5
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
EMPTY = new(NO_CELLS, 0).freeze
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Muxr
|
|
6
|
+
class ImageStore
|
|
7
|
+
IMAGES_DIR = File.join(Dir.home, ".muxr", "images").freeze
|
|
8
|
+
KEEP_RECENT = 200
|
|
9
|
+
PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(dir: IMAGES_DIR, keep: KEEP_RECENT)
|
|
12
|
+
@dir = dir
|
|
13
|
+
@keep = keep
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write(bytes)
|
|
17
|
+
FileUtils.mkdir_p(@dir)
|
|
18
|
+
path = File.join(@dir, "#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(3)}.png")
|
|
19
|
+
File.binwrite(path, bytes)
|
|
20
|
+
prune
|
|
21
|
+
path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.png?(bytes)
|
|
25
|
+
bytes.byteslice(0, PNG_MAGIC.bytesize) == PNG_MAGIC
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.png_dimensions(bytes)
|
|
29
|
+
return nil unless png?(bytes) && bytes.bytesize >= 24
|
|
30
|
+
return nil unless bytes.byteslice(12, 4) == "IHDR"
|
|
31
|
+
bytes.byteslice(16, 8).unpack("NN")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.encode_png(pixels, width, height, channels)
|
|
35
|
+
return nil if width <= 0 || height <= 0
|
|
36
|
+
stride = width * channels
|
|
37
|
+
return nil if pixels.bytesize < stride * height
|
|
38
|
+
scanlines = +"".b
|
|
39
|
+
height.times { |y| scanlines << "\x00".b << pixels.byteslice(y * stride, stride) }
|
|
40
|
+
header = [width, height].pack("NN") << [8, channels == 4 ? 6 : 2, 0, 0, 0].pack("C5")
|
|
41
|
+
+"".b << PNG_MAGIC <<
|
|
42
|
+
png_chunk("IHDR", header) <<
|
|
43
|
+
png_chunk("IDAT", Zlib::Deflate.deflate(scanlines)) <<
|
|
44
|
+
png_chunk("IEND", +"".b)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.png_chunk(type, data)
|
|
48
|
+
body = type.b << data.b
|
|
49
|
+
+"".b << [data.bytesize].pack("N") << body << [Zlib.crc32(body)].pack("N")
|
|
50
|
+
end
|
|
51
|
+
private_class_method :png_chunk
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def prune
|
|
56
|
+
entries = Dir.glob(File.join(@dir, "*.png"))
|
|
57
|
+
return if entries.length <= @keep
|
|
58
|
+
FileUtils.rm_f(entries.sort_by { |p| File.mtime(p) }.first(entries.length - @keep))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "muxr/command_dispatcher"
|
|
2
|
+
|
|
1
3
|
module Muxr
|
|
2
4
|
# Translates raw keystrokes into either commands or pane input. Two top-level
|
|
3
5
|
# modes:
|
|
@@ -12,7 +14,7 @@ module Muxr
|
|
|
12
14
|
#
|
|
13
15
|
# Plus the sub-states pre-existing from before modes existed:
|
|
14
16
|
# :prefix, :command, :confirm_quit, :confirm_close, :help, :scrollback,
|
|
15
|
-
# :search, :selection.
|
|
17
|
+
# :search, :selection, :pane_picker.
|
|
16
18
|
#
|
|
17
19
|
# One-shot sub-states (prefix, command, confirm_quit, help) return to
|
|
18
20
|
# @base_mode (whichever of :normal/:passthrough is active) when they
|
|
@@ -48,9 +50,15 @@ module Muxr
|
|
|
48
50
|
"f" => [:set_layout, :spiral],
|
|
49
51
|
"e" => [:set_layout, :centered],
|
|
50
52
|
"S" => [:set_layout, :stack],
|
|
53
|
+
"F" => [:set_layout, :auto],
|
|
51
54
|
"\t" => :cycle_layout,
|
|
52
55
|
"\r" => :promote_master,
|
|
53
56
|
"\n" => :promote_master,
|
|
57
|
+
"<" => :shrink_master,
|
|
58
|
+
">" => :grow_master,
|
|
59
|
+
"," => :remove_master,
|
|
60
|
+
"." => :add_master,
|
|
61
|
+
"z" => :toggle_zoom,
|
|
54
62
|
"h" => [:focus_direction, :left],
|
|
55
63
|
"j" => [:focus_direction, :down],
|
|
56
64
|
"k" => [:focus_direction, :up],
|
|
@@ -65,6 +73,7 @@ module Muxr
|
|
|
65
73
|
"C" => :toggle_claude_drawer,
|
|
66
74
|
"P" => :toggle_private_focused,
|
|
67
75
|
"d" => :detach,
|
|
76
|
+
"A" => :open_pane_picker,
|
|
68
77
|
"?" => :show_help,
|
|
69
78
|
"q" => :quit_immediate,
|
|
70
79
|
"s" => :enter_scrollback,
|
|
@@ -81,10 +90,16 @@ module Muxr
|
|
|
81
90
|
"\t" => :cycle_layout,
|
|
82
91
|
"\r" => :promote_master,
|
|
83
92
|
"\n" => :promote_master,
|
|
93
|
+
"<" => :shrink_master,
|
|
94
|
+
">" => :grow_master,
|
|
95
|
+
"," => :remove_master,
|
|
96
|
+
"." => :add_master,
|
|
97
|
+
"z" => :toggle_zoom,
|
|
84
98
|
"~" => :toggle_drawer,
|
|
85
99
|
"C" => :toggle_claude_drawer,
|
|
86
100
|
"P" => :toggle_private_focused,
|
|
87
101
|
"d" => :detach,
|
|
102
|
+
"A" => :open_pane_picker,
|
|
88
103
|
"?" => :show_help,
|
|
89
104
|
"q" => :quit_immediate,
|
|
90
105
|
"[" => :enter_scrollback,
|
|
@@ -122,6 +137,7 @@ module Muxr
|
|
|
122
137
|
}.freeze
|
|
123
138
|
|
|
124
139
|
SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
140
|
+
SCROLL_SOURCE_TOGGLE = "\t"
|
|
125
141
|
|
|
126
142
|
SELECTION_BINDINGS = {
|
|
127
143
|
"h" => :left,
|
|
@@ -155,18 +171,43 @@ module Muxr
|
|
|
155
171
|
# `v` so the right thumb has a one-key way to anchor/release.
|
|
156
172
|
}.freeze
|
|
157
173
|
|
|
174
|
+
# Attach overlay. Navigation mirrors scrollback's j/k plus the arrow keys
|
|
175
|
+
# (peeled off by the same CSI lookahead), Enter attaches, and every quit
|
|
176
|
+
# gesture the other one-shot overlays accept backs out.
|
|
177
|
+
PICKER_BINDINGS = {
|
|
178
|
+
"j" => 1,
|
|
179
|
+
"k" => -1,
|
|
180
|
+
"\x0e" => 1, # Ctrl-n
|
|
181
|
+
"\x10" => -1 # Ctrl-p
|
|
182
|
+
}.freeze
|
|
183
|
+
|
|
184
|
+
PICKER_CSI = {
|
|
185
|
+
"\e[A" => -1, # Up
|
|
186
|
+
"\e[B" => 1 # Down
|
|
187
|
+
}.freeze
|
|
188
|
+
|
|
189
|
+
PICKER_CANCEL = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
190
|
+
PICKER_CONFIRM = ["\r", "\n", " "].freeze
|
|
191
|
+
# `m` takes the pane instead of sharing it: it leaves the session it came
|
|
192
|
+
# from and this one owns the shell afterwards.
|
|
193
|
+
PICKER_MOVE = ["m"].freeze
|
|
194
|
+
|
|
158
195
|
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
159
196
|
SELECTION_CANCEL = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
160
197
|
|
|
161
198
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
162
199
|
|
|
163
|
-
attr_reader :state, :command_buffer, :search_buffer, :search_direction, :base_mode
|
|
200
|
+
attr_reader :state, :command_buffer, :command_completions, :search_buffer, :search_direction, :base_mode, :scroll_source, :prefix
|
|
164
201
|
|
|
165
202
|
def initialize(app)
|
|
166
203
|
@app = app
|
|
167
204
|
@state = :normal
|
|
168
205
|
@base_mode = :normal
|
|
206
|
+
@scroll_source = :ring
|
|
169
207
|
@command_buffer = +""
|
|
208
|
+
# Ambiguous Tab-completion candidates for the current command buffer,
|
|
209
|
+
# shown as a hint in the prompt. nil when there's nothing to disambiguate.
|
|
210
|
+
@command_completions = nil
|
|
170
211
|
@search_buffer = +""
|
|
171
212
|
@search_direction = :forward
|
|
172
213
|
# When the prefix state is entered from scrollback/selection (Ctrl-a),
|
|
@@ -174,6 +215,13 @@ module Muxr
|
|
|
174
215
|
# scrollback on the newly-focused pane instead of dropping to the base
|
|
175
216
|
# mode. nil means "use @base_mode" (the normal passthrough behavior).
|
|
176
217
|
@prefix_return = nil
|
|
218
|
+
configure(nil)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def configure(config)
|
|
222
|
+
@prefix = config ? config.prefix : PREFIX
|
|
223
|
+
@normal_bindings = NORMAL_BINDINGS.merge(config ? config.normal_keys : {})
|
|
224
|
+
@prefix_bindings = PREFIX_BINDINGS.merge(config ? config.prefix_keys : {})
|
|
177
225
|
end
|
|
178
226
|
|
|
179
227
|
def feed(data)
|
|
@@ -183,7 +231,7 @@ module Muxr
|
|
|
183
231
|
# Fast path: batch everything up to the next Ctrl-a as one chunk so
|
|
184
232
|
# a large paste doesn't turn into one PTY write per byte. PREFIX is
|
|
185
233
|
# single-byte ASCII (\x01) and never appears mid-UTF-8.
|
|
186
|
-
idx = remaining.index(
|
|
234
|
+
idx = remaining.index(@prefix)
|
|
187
235
|
if idx.nil?
|
|
188
236
|
@app.send_to_focused(remaining)
|
|
189
237
|
return
|
|
@@ -201,7 +249,7 @@ module Muxr
|
|
|
201
249
|
# doesn't kick the user out of the prompt. An incomplete `\e[…`
|
|
202
250
|
# (rare in raw-mode TTY) falls through and the bare `\e` exits as
|
|
203
251
|
# before.
|
|
204
|
-
if (@state == :scrollback || @state == :search) && remaining.start_with?("\e[")
|
|
252
|
+
if (@state == :scrollback || @state == :search || @state == :pane_picker) && remaining.start_with?("\e[")
|
|
205
253
|
consumed = consume_csi_escape(remaining)
|
|
206
254
|
if consumed > 0
|
|
207
255
|
remaining = remaining[consumed..] || ""
|
|
@@ -231,6 +279,8 @@ module Muxr
|
|
|
231
279
|
handle_search_input(ch)
|
|
232
280
|
when :selection
|
|
233
281
|
handle_selection_input(ch)
|
|
282
|
+
when :pane_picker
|
|
283
|
+
handle_pane_picker_input(ch)
|
|
234
284
|
end
|
|
235
285
|
end
|
|
236
286
|
end
|
|
@@ -247,8 +297,9 @@ module Muxr
|
|
|
247
297
|
@state = :confirm_close
|
|
248
298
|
end
|
|
249
299
|
|
|
250
|
-
def enter_scrollback_mode
|
|
300
|
+
def enter_scrollback_mode(source: nil)
|
|
251
301
|
@state = :scrollback
|
|
302
|
+
@scroll_source = source if source
|
|
252
303
|
end
|
|
253
304
|
|
|
254
305
|
def enter_search_mode(direction: :forward)
|
|
@@ -261,6 +312,10 @@ module Muxr
|
|
|
261
312
|
@state = :selection
|
|
262
313
|
end
|
|
263
314
|
|
|
315
|
+
def enter_pane_picker_mode
|
|
316
|
+
@state = :pane_picker
|
|
317
|
+
end
|
|
318
|
+
|
|
264
319
|
# Drop into passthrough — every key reaches the focused pane until the
|
|
265
320
|
# user issues Ctrl-a Esc.
|
|
266
321
|
def enter_passthrough_mode
|
|
@@ -286,6 +341,7 @@ module Muxr
|
|
|
286
341
|
def cancel
|
|
287
342
|
@state = @base_mode
|
|
288
343
|
@command_buffer = +""
|
|
344
|
+
@command_completions = nil
|
|
289
345
|
end
|
|
290
346
|
|
|
291
347
|
private
|
|
@@ -302,6 +358,7 @@ module Muxr
|
|
|
302
358
|
if ch == ":"
|
|
303
359
|
@state = :command
|
|
304
360
|
@command_buffer = +""
|
|
361
|
+
@command_completions = nil
|
|
305
362
|
return
|
|
306
363
|
end
|
|
307
364
|
if DIGIT_RE.match?(ch)
|
|
@@ -309,7 +366,7 @@ module Muxr
|
|
|
309
366
|
return
|
|
310
367
|
end
|
|
311
368
|
|
|
312
|
-
action =
|
|
369
|
+
action = @normal_bindings[ch]
|
|
313
370
|
case action
|
|
314
371
|
when Symbol
|
|
315
372
|
@app.public_send(action)
|
|
@@ -327,7 +384,7 @@ module Muxr
|
|
|
327
384
|
# Consume it immediately so it never leaks into the next prefix.
|
|
328
385
|
ret = @prefix_return || @base_mode
|
|
329
386
|
@prefix_return = nil
|
|
330
|
-
action =
|
|
387
|
+
action = @prefix_bindings[ch]
|
|
331
388
|
case
|
|
332
389
|
when ch == "\e"
|
|
333
390
|
# Ctrl-a Esc → return to normal mode. Flip state directly so tests
|
|
@@ -338,8 +395,9 @@ module Muxr
|
|
|
338
395
|
when ch == ":"
|
|
339
396
|
@state = :command
|
|
340
397
|
@command_buffer = +""
|
|
341
|
-
|
|
342
|
-
|
|
398
|
+
@command_completions = nil
|
|
399
|
+
when ch == @prefix
|
|
400
|
+
@app.send_to_focused(@prefix)
|
|
343
401
|
@state = @base_mode
|
|
344
402
|
when DIGIT_RE.match?(ch)
|
|
345
403
|
@app.focus_pane_number(ch.to_i)
|
|
@@ -347,7 +405,7 @@ module Muxr
|
|
|
347
405
|
# was left scrolled). Only fall back to `ret` if it didn't.
|
|
348
406
|
@state = ret if @state == :prefix
|
|
349
407
|
when action
|
|
350
|
-
@app.public_send(action)
|
|
408
|
+
@app.public_send(*Array(action))
|
|
351
409
|
# The action may have set a new state (confirm_quit, confirm_close,
|
|
352
410
|
# scrollback via auto-enter, help). Only fall back to `ret` if we're
|
|
353
411
|
# still in :prefix.
|
|
@@ -377,7 +435,7 @@ module Muxr
|
|
|
377
435
|
end
|
|
378
436
|
|
|
379
437
|
def handle_scrollback_input(ch)
|
|
380
|
-
if ch ==
|
|
438
|
+
if ch == @prefix
|
|
381
439
|
# Ctrl-a is the escape hatch even from scrollback: drop into the
|
|
382
440
|
# prefix state so the user can switch panes (Ctrl-a n/p/a/1-9) or
|
|
383
441
|
# run any other prefix binding without first leaving scrollback.
|
|
@@ -388,6 +446,10 @@ module Muxr
|
|
|
388
446
|
@state = :prefix
|
|
389
447
|
return
|
|
390
448
|
end
|
|
449
|
+
if ch == SCROLL_SOURCE_TOGGLE
|
|
450
|
+
@app.toggle_scroll_source
|
|
451
|
+
return
|
|
452
|
+
end
|
|
391
453
|
if ch == "i"
|
|
392
454
|
# Drop straight into insert (passthrough) without snapping to the
|
|
393
455
|
# live bottom — the pane stays where it's scrolled. Mirrors the
|
|
@@ -465,9 +527,13 @@ module Muxr
|
|
|
465
527
|
b = remaining.getbyte(i)
|
|
466
528
|
if b >= 0x40 && b <= 0x7e
|
|
467
529
|
seq = remaining.byteslice(0, i + 1)
|
|
468
|
-
|
|
530
|
+
case @state
|
|
531
|
+
when :scrollback
|
|
469
532
|
action = SCROLLBACK_CSI[seq]
|
|
470
533
|
@app.scroll_focused(action) if action
|
|
534
|
+
when :pane_picker
|
|
535
|
+
delta = PICKER_CSI[seq]
|
|
536
|
+
@app.move_pane_picker(delta) if delta
|
|
471
537
|
end
|
|
472
538
|
return i + 1
|
|
473
539
|
end
|
|
@@ -478,7 +544,7 @@ module Muxr
|
|
|
478
544
|
end
|
|
479
545
|
|
|
480
546
|
def handle_selection_input(ch)
|
|
481
|
-
if ch ==
|
|
547
|
+
if ch == @prefix
|
|
482
548
|
# Same escape hatch as scrollback: Ctrl-a enters the prefix state so
|
|
483
549
|
# pane switching (and any other prefix binding) works mid-selection.
|
|
484
550
|
# We return to :scrollback (not :selection) on the new pane — you
|
|
@@ -509,22 +575,54 @@ module Muxr
|
|
|
509
575
|
# Unknown keys ignored — same rationale as scrollback mode.
|
|
510
576
|
end
|
|
511
577
|
|
|
578
|
+
def handle_pane_picker_input(ch)
|
|
579
|
+
if PICKER_CONFIRM.include?(ch)
|
|
580
|
+
@state = @base_mode
|
|
581
|
+
@app.confirm_pane_picker
|
|
582
|
+
return
|
|
583
|
+
end
|
|
584
|
+
if PICKER_MOVE.include?(ch)
|
|
585
|
+
@state = @base_mode
|
|
586
|
+
@app.confirm_pane_picker(move: true)
|
|
587
|
+
return
|
|
588
|
+
end
|
|
589
|
+
if PICKER_CANCEL.include?(ch)
|
|
590
|
+
@state = @base_mode
|
|
591
|
+
@app.cancel_pane_picker
|
|
592
|
+
return
|
|
593
|
+
end
|
|
594
|
+
delta = PICKER_BINDINGS[ch]
|
|
595
|
+
@app.move_pane_picker(delta) if delta
|
|
596
|
+
# Unknown keys ignored — same rationale as scrollback mode.
|
|
597
|
+
end
|
|
598
|
+
|
|
512
599
|
def handle_command_input(ch)
|
|
513
600
|
case ch
|
|
514
601
|
when "\r", "\n"
|
|
515
602
|
cmd = @command_buffer.dup
|
|
516
603
|
@command_buffer = +""
|
|
604
|
+
@command_completions = nil
|
|
517
605
|
@state = @base_mode
|
|
518
606
|
@app.run_command(cmd)
|
|
519
|
-
when "\e"
|
|
607
|
+
when "\e", "\x03" # Esc or Ctrl-c → abandon the command
|
|
520
608
|
@command_buffer = +""
|
|
609
|
+
@command_completions = nil
|
|
521
610
|
@state = @base_mode
|
|
522
611
|
@app.invalidate
|
|
612
|
+
when "\t"
|
|
613
|
+
new_line, candidates = CommandDispatcher.complete(@command_buffer)
|
|
614
|
+
@command_buffer = +new_line
|
|
615
|
+
# Only keep the hint list when it's still ambiguous; a unique match
|
|
616
|
+
# (or none) leaves nothing useful to show.
|
|
617
|
+
@command_completions = candidates.length > 1 ? candidates : nil
|
|
618
|
+
@app.invalidate
|
|
523
619
|
when "\x7f", "\b"
|
|
524
620
|
@command_buffer.chop!
|
|
621
|
+
@command_completions = nil
|
|
525
622
|
@app.invalidate
|
|
526
623
|
else
|
|
527
624
|
@command_buffer << ch if ch.ord >= 0x20
|
|
625
|
+
@command_completions = nil if ch.ord >= 0x20
|
|
528
626
|
@app.invalidate
|
|
529
627
|
end
|
|
530
628
|
end
|