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.
@@ -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,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(PREFIX)
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 = NORMAL_BINDINGS[ch]
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 = PREFIX_BINDINGS[ch]
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
- when ch == PREFIX
342
- @app.send_to_focused(PREFIX)
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 == PREFIX
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
- if @state == :scrollback
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 == PREFIX
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