muxr 0.1.10 → 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 +275 -1
- data/README.md +763 -387
- data/bin/muxr +68 -33
- data/bin/muxr-mcp +172 -16
- data/lib/muxr/application.rb +639 -35
- 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 +169 -17
- data/lib/muxr/layout_manager.rb +83 -57
- data/lib/muxr/mouse_report.rb +24 -0
- data/lib/muxr/pane.rb +128 -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 +54 -7
- data/lib/muxr/remote_pane.rb +263 -0
- data/lib/muxr/renderer.rb +297 -74
- data/lib/muxr/session.rb +19 -2
- data/lib/muxr/session_directory.rb +102 -0
- data/lib/muxr/terminal.rb +903 -87
- 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,13 +14,22 @@ 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
|
|
19
21
|
# finish. :scrollback and :selection also return to @base_mode so that
|
|
20
22
|
# exiting back from a scroll/yank lands you back in passthrough if that's
|
|
21
23
|
# where you came from.
|
|
24
|
+
#
|
|
25
|
+
# Scrollback is effectively pane-bound. Ctrl-a is honored from inside
|
|
26
|
+
# :scrollback and :selection — it drops into :prefix (with @prefix_return
|
|
27
|
+
# = :scrollback) so a pane switch keeps you in scrollback on the pane you
|
|
28
|
+
# move to, while the pane you left keeps its own scroll position. Coming
|
|
29
|
+
# the other way, the Application re-enters scrollback whenever you focus a
|
|
30
|
+
# pane that was left scrolled back. `i` from scrollback drops to insert
|
|
31
|
+
# (passthrough) without snapping to the bottom; only `q`/Esc returns the
|
|
32
|
+
# pane to the live bottom.
|
|
22
33
|
class InputHandler
|
|
23
34
|
PREFIX = "\x01".freeze # Ctrl-a
|
|
24
35
|
|
|
@@ -39,9 +50,15 @@ module Muxr
|
|
|
39
50
|
"f" => [:set_layout, :spiral],
|
|
40
51
|
"e" => [:set_layout, :centered],
|
|
41
52
|
"S" => [:set_layout, :stack],
|
|
53
|
+
"F" => [:set_layout, :auto],
|
|
42
54
|
"\t" => :cycle_layout,
|
|
43
55
|
"\r" => :promote_master,
|
|
44
56
|
"\n" => :promote_master,
|
|
57
|
+
"<" => :shrink_master,
|
|
58
|
+
">" => :grow_master,
|
|
59
|
+
"," => :remove_master,
|
|
60
|
+
"." => :add_master,
|
|
61
|
+
"z" => :toggle_zoom,
|
|
45
62
|
"h" => [:focus_direction, :left],
|
|
46
63
|
"j" => [:focus_direction, :down],
|
|
47
64
|
"k" => [:focus_direction, :up],
|
|
@@ -51,10 +68,12 @@ module Muxr
|
|
|
51
68
|
"K" => [:move_direction, :up],
|
|
52
69
|
"L" => [:move_direction, :right],
|
|
53
70
|
"a" => :focus_last,
|
|
71
|
+
"r" => :refresh_focused,
|
|
54
72
|
"~" => :toggle_drawer,
|
|
55
73
|
"C" => :toggle_claude_drawer,
|
|
56
74
|
"P" => :toggle_private_focused,
|
|
57
75
|
"d" => :detach,
|
|
76
|
+
"A" => :open_pane_picker,
|
|
58
77
|
"?" => :show_help,
|
|
59
78
|
"q" => :quit_immediate,
|
|
60
79
|
"s" => :enter_scrollback,
|
|
@@ -66,14 +85,21 @@ module Muxr
|
|
|
66
85
|
"n" => :focus_next,
|
|
67
86
|
"p" => :focus_prev,
|
|
68
87
|
"a" => :focus_last,
|
|
88
|
+
"r" => :refresh_focused,
|
|
69
89
|
"x" => :request_close,
|
|
70
90
|
"\t" => :cycle_layout,
|
|
71
91
|
"\r" => :promote_master,
|
|
72
92
|
"\n" => :promote_master,
|
|
93
|
+
"<" => :shrink_master,
|
|
94
|
+
">" => :grow_master,
|
|
95
|
+
"," => :remove_master,
|
|
96
|
+
"." => :add_master,
|
|
97
|
+
"z" => :toggle_zoom,
|
|
73
98
|
"~" => :toggle_drawer,
|
|
74
99
|
"C" => :toggle_claude_drawer,
|
|
75
100
|
"P" => :toggle_private_focused,
|
|
76
101
|
"d" => :detach,
|
|
102
|
+
"A" => :open_pane_picker,
|
|
77
103
|
"?" => :show_help,
|
|
78
104
|
"q" => :quit_immediate,
|
|
79
105
|
"[" => :enter_scrollback,
|
|
@@ -111,6 +137,7 @@ module Muxr
|
|
|
111
137
|
}.freeze
|
|
112
138
|
|
|
113
139
|
SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
140
|
+
SCROLL_SOURCE_TOGGLE = "\t"
|
|
114
141
|
|
|
115
142
|
SELECTION_BINDINGS = {
|
|
116
143
|
"h" => :left,
|
|
@@ -144,20 +171,57 @@ module Muxr
|
|
|
144
171
|
# `v` so the right thumb has a one-key way to anchor/release.
|
|
145
172
|
}.freeze
|
|
146
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
|
+
|
|
147
195
|
SELECTION_YANK = ["\r", "\n", "y"].freeze
|
|
148
196
|
SELECTION_CANCEL = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
|
|
149
197
|
|
|
150
198
|
DIGIT_RE = /\A[1-9]\z/.freeze
|
|
151
199
|
|
|
152
|
-
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
|
|
153
201
|
|
|
154
202
|
def initialize(app)
|
|
155
203
|
@app = app
|
|
156
204
|
@state = :normal
|
|
157
205
|
@base_mode = :normal
|
|
206
|
+
@scroll_source = :ring
|
|
158
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
|
|
159
211
|
@search_buffer = +""
|
|
160
212
|
@search_direction = :forward
|
|
213
|
+
# When the prefix state is entered from scrollback/selection (Ctrl-a),
|
|
214
|
+
# this records :scrollback so that a pane switch lands you back in
|
|
215
|
+
# scrollback on the newly-focused pane instead of dropping to the base
|
|
216
|
+
# mode. nil means "use @base_mode" (the normal passthrough behavior).
|
|
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 : {})
|
|
161
225
|
end
|
|
162
226
|
|
|
163
227
|
def feed(data)
|
|
@@ -167,7 +231,7 @@ module Muxr
|
|
|
167
231
|
# Fast path: batch everything up to the next Ctrl-a as one chunk so
|
|
168
232
|
# a large paste doesn't turn into one PTY write per byte. PREFIX is
|
|
169
233
|
# single-byte ASCII (\x01) and never appears mid-UTF-8.
|
|
170
|
-
idx = remaining.index(
|
|
234
|
+
idx = remaining.index(@prefix)
|
|
171
235
|
if idx.nil?
|
|
172
236
|
@app.send_to_focused(remaining)
|
|
173
237
|
return
|
|
@@ -185,7 +249,7 @@ module Muxr
|
|
|
185
249
|
# doesn't kick the user out of the prompt. An incomplete `\e[…`
|
|
186
250
|
# (rare in raw-mode TTY) falls through and the bare `\e` exits as
|
|
187
251
|
# before.
|
|
188
|
-
if (@state == :scrollback || @state == :search) && remaining.start_with?("\e[")
|
|
252
|
+
if (@state == :scrollback || @state == :search || @state == :pane_picker) && remaining.start_with?("\e[")
|
|
189
253
|
consumed = consume_csi_escape(remaining)
|
|
190
254
|
if consumed > 0
|
|
191
255
|
remaining = remaining[consumed..] || ""
|
|
@@ -215,6 +279,8 @@ module Muxr
|
|
|
215
279
|
handle_search_input(ch)
|
|
216
280
|
when :selection
|
|
217
281
|
handle_selection_input(ch)
|
|
282
|
+
when :pane_picker
|
|
283
|
+
handle_pane_picker_input(ch)
|
|
218
284
|
end
|
|
219
285
|
end
|
|
220
286
|
end
|
|
@@ -231,8 +297,9 @@ module Muxr
|
|
|
231
297
|
@state = :confirm_close
|
|
232
298
|
end
|
|
233
299
|
|
|
234
|
-
def enter_scrollback_mode
|
|
300
|
+
def enter_scrollback_mode(source: nil)
|
|
235
301
|
@state = :scrollback
|
|
302
|
+
@scroll_source = source if source
|
|
236
303
|
end
|
|
237
304
|
|
|
238
305
|
def enter_search_mode(direction: :forward)
|
|
@@ -245,6 +312,10 @@ module Muxr
|
|
|
245
312
|
@state = :selection
|
|
246
313
|
end
|
|
247
314
|
|
|
315
|
+
def enter_pane_picker_mode
|
|
316
|
+
@state = :pane_picker
|
|
317
|
+
end
|
|
318
|
+
|
|
248
319
|
# Drop into passthrough — every key reaches the focused pane until the
|
|
249
320
|
# user issues Ctrl-a Esc.
|
|
250
321
|
def enter_passthrough_mode
|
|
@@ -270,6 +341,7 @@ module Muxr
|
|
|
270
341
|
def cancel
|
|
271
342
|
@state = @base_mode
|
|
272
343
|
@command_buffer = +""
|
|
344
|
+
@command_completions = nil
|
|
273
345
|
end
|
|
274
346
|
|
|
275
347
|
private
|
|
@@ -286,6 +358,7 @@ module Muxr
|
|
|
286
358
|
if ch == ":"
|
|
287
359
|
@state = :command
|
|
288
360
|
@command_buffer = +""
|
|
361
|
+
@command_completions = nil
|
|
289
362
|
return
|
|
290
363
|
end
|
|
291
364
|
if DIGIT_RE.match?(ch)
|
|
@@ -293,7 +366,7 @@ module Muxr
|
|
|
293
366
|
return
|
|
294
367
|
end
|
|
295
368
|
|
|
296
|
-
action =
|
|
369
|
+
action = @normal_bindings[ch]
|
|
297
370
|
case action
|
|
298
371
|
when Symbol
|
|
299
372
|
@app.public_send(action)
|
|
@@ -305,7 +378,13 @@ module Muxr
|
|
|
305
378
|
end
|
|
306
379
|
|
|
307
380
|
def handle_prefix(ch)
|
|
308
|
-
|
|
381
|
+
# Where to land once the prefix binding finishes. Normally the base
|
|
382
|
+
# mode, but :scrollback when we entered the prefix from scrollback /
|
|
383
|
+
# selection so a pane switch keeps you in scrollback on the new pane.
|
|
384
|
+
# Consume it immediately so it never leaks into the next prefix.
|
|
385
|
+
ret = @prefix_return || @base_mode
|
|
386
|
+
@prefix_return = nil
|
|
387
|
+
action = @prefix_bindings[ch]
|
|
309
388
|
case
|
|
310
389
|
when ch == "\e"
|
|
311
390
|
# Ctrl-a Esc → return to normal mode. Flip state directly so tests
|
|
@@ -316,20 +395,24 @@ module Muxr
|
|
|
316
395
|
when ch == ":"
|
|
317
396
|
@state = :command
|
|
318
397
|
@command_buffer = +""
|
|
319
|
-
|
|
320
|
-
|
|
398
|
+
@command_completions = nil
|
|
399
|
+
when ch == @prefix
|
|
400
|
+
@app.send_to_focused(@prefix)
|
|
321
401
|
@state = @base_mode
|
|
322
402
|
when DIGIT_RE.match?(ch)
|
|
323
403
|
@app.focus_pane_number(ch.to_i)
|
|
324
|
-
|
|
404
|
+
# The focus action may auto-enter scrollback (landing on a pane that
|
|
405
|
+
# was left scrolled). Only fall back to `ret` if it didn't.
|
|
406
|
+
@state = ret if @state == :prefix
|
|
325
407
|
when action
|
|
326
|
-
@app.public_send(action)
|
|
408
|
+
@app.public_send(*Array(action))
|
|
327
409
|
# The action may have set a new state (confirm_quit, confirm_close,
|
|
328
|
-
# scrollback, help). Only
|
|
329
|
-
|
|
410
|
+
# scrollback via auto-enter, help). Only fall back to `ret` if we're
|
|
411
|
+
# still in :prefix.
|
|
412
|
+
@state = ret if @state == :prefix
|
|
330
413
|
else
|
|
331
|
-
# Unknown prefix key: return to
|
|
332
|
-
@state =
|
|
414
|
+
# Unknown prefix key: return to where we came from silently.
|
|
415
|
+
@state = ret
|
|
333
416
|
end
|
|
334
417
|
end
|
|
335
418
|
|
|
@@ -352,6 +435,29 @@ module Muxr
|
|
|
352
435
|
end
|
|
353
436
|
|
|
354
437
|
def handle_scrollback_input(ch)
|
|
438
|
+
if ch == @prefix
|
|
439
|
+
# Ctrl-a is the escape hatch even from scrollback: drop into the
|
|
440
|
+
# prefix state so the user can switch panes (Ctrl-a n/p/a/1-9) or
|
|
441
|
+
# run any other prefix binding without first leaving scrollback.
|
|
442
|
+
# @prefix_return = :scrollback keeps the user in scrollback on the
|
|
443
|
+
# pane they switch to; the source pane keeps its scroll position so
|
|
444
|
+
# it stays put. Scrollback is effectively pane-bound now.
|
|
445
|
+
@prefix_return = :scrollback
|
|
446
|
+
@state = :prefix
|
|
447
|
+
return
|
|
448
|
+
end
|
|
449
|
+
if ch == SCROLL_SOURCE_TOGGLE
|
|
450
|
+
@app.toggle_scroll_source
|
|
451
|
+
return
|
|
452
|
+
end
|
|
453
|
+
if ch == "i"
|
|
454
|
+
# Drop straight into insert (passthrough) without snapping to the
|
|
455
|
+
# live bottom — the pane stays where it's scrolled. Mirrors the
|
|
456
|
+
# normal-mode `i` so "type now" is one key from scrollback too.
|
|
457
|
+
enter_passthrough_mode
|
|
458
|
+
@app.enter_passthrough_mode
|
|
459
|
+
return
|
|
460
|
+
end
|
|
355
461
|
if SCROLLBACK_EXITS.include?(ch)
|
|
356
462
|
enter_idle_mode
|
|
357
463
|
@app.exit_scrollback
|
|
@@ -421,9 +527,13 @@ module Muxr
|
|
|
421
527
|
b = remaining.getbyte(i)
|
|
422
528
|
if b >= 0x40 && b <= 0x7e
|
|
423
529
|
seq = remaining.byteslice(0, i + 1)
|
|
424
|
-
|
|
530
|
+
case @state
|
|
531
|
+
when :scrollback
|
|
425
532
|
action = SCROLLBACK_CSI[seq]
|
|
426
533
|
@app.scroll_focused(action) if action
|
|
534
|
+
when :pane_picker
|
|
535
|
+
delta = PICKER_CSI[seq]
|
|
536
|
+
@app.move_pane_picker(delta) if delta
|
|
427
537
|
end
|
|
428
538
|
return i + 1
|
|
429
539
|
end
|
|
@@ -434,6 +544,16 @@ module Muxr
|
|
|
434
544
|
end
|
|
435
545
|
|
|
436
546
|
def handle_selection_input(ch)
|
|
547
|
+
if ch == @prefix
|
|
548
|
+
# Same escape hatch as scrollback: Ctrl-a enters the prefix state so
|
|
549
|
+
# pane switching (and any other prefix binding) works mid-selection.
|
|
550
|
+
# We return to :scrollback (not :selection) on the new pane — you
|
|
551
|
+
# don't want to be mid-select on a pane you just arrived at — while
|
|
552
|
+
# the source pane keeps its scroll position and selection intact.
|
|
553
|
+
@prefix_return = :scrollback
|
|
554
|
+
@state = :prefix
|
|
555
|
+
return
|
|
556
|
+
end
|
|
437
557
|
if SELECTION_YANK.include?(ch)
|
|
438
558
|
@app.exit_selection(yank: true)
|
|
439
559
|
return
|
|
@@ -455,22 +575,54 @@ module Muxr
|
|
|
455
575
|
# Unknown keys ignored — same rationale as scrollback mode.
|
|
456
576
|
end
|
|
457
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
|
+
|
|
458
599
|
def handle_command_input(ch)
|
|
459
600
|
case ch
|
|
460
601
|
when "\r", "\n"
|
|
461
602
|
cmd = @command_buffer.dup
|
|
462
603
|
@command_buffer = +""
|
|
604
|
+
@command_completions = nil
|
|
463
605
|
@state = @base_mode
|
|
464
606
|
@app.run_command(cmd)
|
|
465
|
-
when "\e"
|
|
607
|
+
when "\e", "\x03" # Esc or Ctrl-c → abandon the command
|
|
466
608
|
@command_buffer = +""
|
|
609
|
+
@command_completions = nil
|
|
467
610
|
@state = @base_mode
|
|
468
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
|
|
469
619
|
when "\x7f", "\b"
|
|
470
620
|
@command_buffer.chop!
|
|
621
|
+
@command_completions = nil
|
|
471
622
|
@app.invalidate
|
|
472
623
|
else
|
|
473
624
|
@command_buffer << ch if ch.ord >= 0x20
|
|
625
|
+
@command_completions = nil if ch.ord >= 0x20
|
|
474
626
|
@app.invalidate
|
|
475
627
|
end
|
|
476
628
|
end
|