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.
@@ -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
@@ -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(PREFIX)
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 = NORMAL_BINDINGS[ch]
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
- action = PREFIX_BINDINGS[ch]
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
- when ch == PREFIX
320
- @app.send_to_focused(PREFIX)
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
- @state = @base_mode
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 revert to base mode if we're still in :prefix.
329
- @state = @base_mode if @state == :prefix
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 base mode silently.
332
- @state = @base_mode
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
- if @state == :scrollback
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