mui 0.2.0 → 0.3.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +13 -8
  3. data/CHANGELOG.md +99 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +301 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +149 -0
  13. data/lib/mui/command_completer.rb +11 -2
  14. data/lib/mui/command_history.rb +89 -0
  15. data/lib/mui/command_line.rb +32 -2
  16. data/lib/mui/command_registry.rb +21 -2
  17. data/lib/mui/config.rb +3 -1
  18. data/lib/mui/editor.rb +78 -2
  19. data/lib/mui/handler_result.rb +13 -7
  20. data/lib/mui/highlighters/search_highlighter.rb +2 -1
  21. data/lib/mui/highlighters/syntax_highlighter.rb +3 -1
  22. data/lib/mui/key_handler/base.rb +87 -0
  23. data/lib/mui/key_handler/command_mode.rb +68 -0
  24. data/lib/mui/key_handler/insert_mode.rb +10 -41
  25. data/lib/mui/key_handler/normal_mode.rb +24 -51
  26. data/lib/mui/key_handler/operators/paste_operator.rb +9 -3
  27. data/lib/mui/key_handler/search_mode.rb +10 -7
  28. data/lib/mui/key_handler/visual_mode.rb +15 -10
  29. data/lib/mui/key_notation_parser.rb +152 -0
  30. data/lib/mui/key_sequence.rb +67 -0
  31. data/lib/mui/key_sequence_buffer.rb +85 -0
  32. data/lib/mui/key_sequence_handler.rb +163 -0
  33. data/lib/mui/key_sequence_matcher.rb +79 -0
  34. data/lib/mui/line_renderer.rb +52 -1
  35. data/lib/mui/mode_manager.rb +3 -2
  36. data/lib/mui/screen.rb +24 -6
  37. data/lib/mui/search_state.rb +61 -28
  38. data/lib/mui/syntax/language_detector.rb +33 -1
  39. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  40. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  41. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  42. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  43. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  44. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  45. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  46. data/lib/mui/terminal_adapter/curses.rb +13 -11
  47. data/lib/mui/version.rb +1 -1
  48. data/lib/mui/window.rb +83 -40
  49. data/lib/mui/window_manager.rb +7 -0
  50. data/lib/mui/wrap_cache.rb +40 -0
  51. data/lib/mui/wrap_helper.rb +84 -0
  52. data/lib/mui.rb +15 -0
  53. metadata +26 -3
@@ -37,6 +37,7 @@ module Mui
37
37
  private
38
38
 
39
39
  def paste_line_after(name: nil)
40
+ undo_manager&.begin_group
40
41
  text = @register.get(name:)
41
42
  lines = text.split("\n", -1)
42
43
  lines.reverse_each do |line|
@@ -44,15 +45,18 @@ module Mui
44
45
  end
45
46
  self.cursor_row = cursor_row + 1
46
47
  self.cursor_col = 0
48
+ undo_manager&.end_group
47
49
  end
48
50
 
49
51
  def paste_line_before(name: nil)
52
+ undo_manager&.begin_group
50
53
  text = @register.get(name:)
51
54
  lines = text.split("\n", -1)
52
55
  lines.reverse_each do |line|
53
56
  @buffer.insert_line(cursor_row, line)
54
57
  end
55
58
  self.cursor_col = 0
59
+ undo_manager&.end_group
56
60
  end
57
61
 
58
62
  def paste_char_after(name: nil)
@@ -63,7 +67,7 @@ module Mui
63
67
  if text.include?("\n")
64
68
  paste_multiline_char(text, line, insert_col)
65
69
  else
66
- @buffer.lines[cursor_row] = line[0...insert_col].to_s + text + line[insert_col..].to_s
70
+ @buffer.replace_line(cursor_row, line[0...insert_col].to_s + text + line[insert_col..].to_s)
67
71
  self.cursor_col = insert_col + text.length - 1
68
72
  @window.clamp_cursor_to_line(@buffer)
69
73
  end
@@ -76,19 +80,20 @@ module Mui
76
80
  if text.include?("\n")
77
81
  paste_multiline_char(text, line, cursor_col)
78
82
  else
79
- @buffer.lines[cursor_row] = line[0...cursor_col].to_s + text + line[cursor_col..].to_s
83
+ @buffer.replace_line(cursor_row, line[0...cursor_col].to_s + text + line[cursor_col..].to_s)
80
84
  self.cursor_col = cursor_col + text.length - 1
81
85
  @window.clamp_cursor_to_line(@buffer)
82
86
  end
83
87
  end
84
88
 
85
89
  def paste_multiline_char(text, line, insert_col)
90
+ undo_manager&.begin_group
86
91
  lines = text.split("\n", -1)
87
92
  before = line[0...insert_col].to_s
88
93
  after = line[insert_col..].to_s
89
94
 
90
95
  # First line: before + first part of pasted text
91
- @buffer.lines[cursor_row] = before + lines.first
96
+ @buffer.replace_line(cursor_row, before + lines.first)
92
97
 
93
98
  # Middle lines: insert as new lines
94
99
  lines[1...-1].each_with_index do |pasted_line, idx|
@@ -106,6 +111,7 @@ module Mui
106
111
  self.cursor_col = lines.last.length - 1
107
112
  self.cursor_col = 0 if cursor_col.negative?
108
113
  @window.clamp_cursor_to_line(@buffer)
114
+ undo_manager&.end_group
109
115
  end
110
116
  end
111
117
  end
@@ -23,6 +23,10 @@ module Mui
23
23
  end
24
24
 
25
25
  def handle(key)
26
+ # Check plugin keymaps first
27
+ plugin_result = check_plugin_keymap(key, :search)
28
+ return plugin_result if plugin_result
29
+
26
30
  case key
27
31
  when KeyCode::ESCAPE
28
32
  handle_escape
@@ -136,19 +140,19 @@ module Mui
136
140
 
137
141
  direction = @search_input.prompt == "/" ? :forward : :backward
138
142
  @search_state.set_pattern(pattern, direction)
139
- @search_state.find_all_matches(buffer)
140
143
 
141
144
  # Move cursor to first match from original position
142
- return if @search_state.matches.empty?
145
+ matches = @search_state.find_all_matches(buffer)
146
+ return if matches.empty?
143
147
 
144
148
  # Use original position if set, otherwise use current cursor position
145
149
  search_row = @original_cursor_row || cursor_row
146
150
  search_col = @original_cursor_col || cursor_col
147
151
 
148
152
  match = if direction == :forward
149
- @search_state.find_next(search_row, search_col)
153
+ @search_state.find_next(search_row, search_col, buffer:)
150
154
  else
151
- @search_state.find_previous(search_row, search_col)
155
+ @search_state.find_previous(search_row, search_col, buffer:)
152
156
  end
153
157
 
154
158
  return unless match
@@ -163,12 +167,11 @@ module Mui
163
167
 
164
168
  direction = @search_input.prompt == "/" ? :forward : :backward
165
169
  @search_state.set_pattern(pattern, direction)
166
- @search_state.find_all_matches(@buffer)
167
170
 
168
171
  match = if direction == :forward
169
- @search_state.find_next(cursor_row, cursor_col)
172
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
170
173
  else
171
- @search_state.find_previous(cursor_row, cursor_col)
174
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
172
175
  end
173
176
 
174
177
  if match
@@ -18,6 +18,12 @@ module Mui
18
18
  end
19
19
 
20
20
  def handle(key)
21
+ # Check plugin keymaps first (only when no pending motion)
22
+ unless @pending_motion
23
+ plugin_result = check_plugin_keymap(key, :visual)
24
+ return plugin_result if plugin_result
25
+ end
26
+
21
27
  if @pending_motion
22
28
  handle_pending_motion(key)
23
29
  else
@@ -120,7 +126,7 @@ module Mui
120
126
  if @selection.line_mode
121
127
  change_lines(range)
122
128
  else
123
- @undo_manager&.begin_group
129
+ undo_manager&.begin_group
124
130
  change_range(range)
125
131
  end
126
132
  @pending_register = nil
@@ -154,16 +160,15 @@ module Mui
154
160
  # Escape special regex characters for literal search
155
161
  escaped_pattern = Regexp.escape(text)
156
162
 
157
- # Set search state and find matches
163
+ # Set search state
158
164
  search_state = @mode_manager.search_state
159
165
  search_state.set_pattern(escaped_pattern, direction)
160
- search_state.find_all_matches(buffer)
161
166
 
162
167
  # Find next/previous match from current position
163
168
  match = if direction == :forward
164
- search_state.find_next(cursor_row, cursor_col)
169
+ search_state.find_next(cursor_row, cursor_col, buffer:)
165
170
  else
166
- search_state.find_previous(cursor_row, cursor_col)
171
+ search_state.find_previous(cursor_row, cursor_col, buffer:)
167
172
  end
168
173
 
169
174
  if match
@@ -207,7 +212,7 @@ module Mui
207
212
  def change_lines(range)
208
213
  lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
209
214
  @register.delete(lines.join("\n"), linewise: true, name: @pending_register)
210
- @undo_manager&.begin_group
215
+ undo_manager&.begin_group
211
216
  (range[:end_row] - range[:start_row] + 1).times do
212
217
  buffer.delete_line(range[:start_row])
213
218
  end
@@ -229,11 +234,11 @@ module Mui
229
234
  def delete_lines(range)
230
235
  lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
231
236
  @register.delete(lines.join("\n"), linewise: true, name: @pending_register)
232
- @undo_manager&.begin_group unless @undo_manager&.in_group?
237
+ undo_manager&.begin_group unless undo_manager&.in_group?
233
238
  (range[:end_row] - range[:start_row] + 1).times do
234
239
  buffer.delete_line(range[:start_row])
235
240
  end
236
- @undo_manager&.end_group
241
+ undo_manager&.end_group
237
242
  self.cursor_row = [range[:start_row], buffer.line_count - 1].min
238
243
  self.cursor_col = 0
239
244
  window.clamp_cursor_to_line(buffer)
@@ -268,7 +273,7 @@ module Mui
268
273
  def indent_lines(start_row, end_row, direction)
269
274
  indent_string = build_indent_string
270
275
 
271
- @undo_manager&.begin_group unless @undo_manager&.in_group?
276
+ undo_manager&.begin_group unless undo_manager&.in_group?
272
277
 
273
278
  (start_row..end_row).each do |row|
274
279
  if direction == :right
@@ -278,7 +283,7 @@ module Mui
278
283
  end
279
284
  end
280
285
 
281
- @undo_manager&.end_group
286
+ undo_manager&.end_group
282
287
  end
283
288
 
284
289
  def build_indent_string
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Mui
6
+ # Parser for Vim-style key notation strings
7
+ # Converts notation like "<Leader>gd", "<C-x><C-s>", "<Space>w" to internal key arrays
8
+ module KeyNotationParser
9
+ # Special key mappings (case-insensitive)
10
+ SPECIAL_KEYS = {
11
+ "space" => " ",
12
+ "tab" => "\t",
13
+ "cr" => "\r",
14
+ "enter" => "\r",
15
+ "return" => "\r",
16
+ "esc" => "\e",
17
+ "escape" => "\e",
18
+ "bs" => "\x7f",
19
+ "backspace" => "\x7f",
20
+ "del" => "\x7f",
21
+ "delete" => "\x7f",
22
+ "lt" => "<",
23
+ "gt" => ">",
24
+ "bar" => "|",
25
+ "bslash" => "\\",
26
+ "leader" => :leader
27
+ }.freeze
28
+
29
+ # Ctrl key mappings (a-z and some special characters)
30
+ CTRL_CHARS = {
31
+ "@" => 0, # NUL
32
+ "a" => 1,
33
+ "b" => 2,
34
+ "c" => 3,
35
+ "d" => 4,
36
+ "e" => 5,
37
+ "f" => 6,
38
+ "g" => 7,
39
+ "h" => 8, # Also backspace
40
+ "i" => 9, # Also tab
41
+ "j" => 10, # Also newline
42
+ "k" => 11,
43
+ "l" => 12,
44
+ "m" => 13, # Also carriage return
45
+ "n" => 14,
46
+ "o" => 15,
47
+ "p" => 16,
48
+ "q" => 17,
49
+ "r" => 18,
50
+ "s" => 19,
51
+ "t" => 20,
52
+ "u" => 21,
53
+ "v" => 22,
54
+ "w" => 23,
55
+ "x" => 24,
56
+ "y" => 25,
57
+ "z" => 26,
58
+ "[" => 27, # Also escape
59
+ "\\" => 28,
60
+ "]" => 29,
61
+ "^" => 30,
62
+ "_" => 31
63
+ }.freeze
64
+
65
+ class << self
66
+ # Parse a key notation string into an array of keys
67
+ # @param notation [String] Key notation (e.g., "<Leader>gd", "<C-x><C-s>")
68
+ # @return [Array<String, Symbol>] Array of normalized keys
69
+ def parse(notation)
70
+ return [] if notation.nil? || notation.empty?
71
+
72
+ tokens = []
73
+ scanner = StringScanner.new(notation)
74
+
75
+ until scanner.eos?
76
+ if scanner.scan(/<([^>]+)>/)
77
+ # Special key notation <...>
78
+ tokens << parse_special(scanner[1])
79
+ else
80
+ # Regular character
81
+ char = scanner.getch
82
+ tokens << char if char
83
+ end
84
+ end
85
+
86
+ tokens
87
+ end
88
+
89
+ # Parse a special key notation (content inside < >)
90
+ # @param name [String] Special key name (e.g., "C-x", "Leader", "Space")
91
+ # @return [String, Symbol] Normalized key
92
+ def parse_special(name)
93
+ return :leader if name.casecmp?("leader")
94
+
95
+ # Handle Ctrl key: <C-x>, <Ctrl-x>, <C-X>
96
+ return parse_ctrl_key(::Regexp.last_match(2)) if name =~ /\A(c|ctrl)-(.+)\z/i
97
+
98
+ # Handle Shift key: <S-x>, <Shift-x>
99
+ return parse_shift_key(::Regexp.last_match(2)) if name =~ /\A(s|shift)-(.+)\z/i
100
+
101
+ # Handle other special keys
102
+ normalized_name = name.downcase
103
+ SPECIAL_KEYS[normalized_name] || name
104
+ end
105
+
106
+ # Normalize an input key (from terminal) to internal representation
107
+ # @param key [Integer, String] Raw key input
108
+ # @return [String, nil] Normalized key string, or nil if invalid
109
+ def normalize_input_key(key)
110
+ case key
111
+ when String
112
+ key
113
+ when Integer
114
+ normalize_integer_key(key)
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def parse_ctrl_key(char)
121
+ char_lower = char.downcase
122
+ code = CTRL_CHARS[char_lower]
123
+ code ? code.chr : char
124
+ end
125
+
126
+ def parse_shift_key(char)
127
+ # Shift typically produces uppercase for letters
128
+ char.length == 1 ? char.upcase : char
129
+ end
130
+
131
+ def normalize_integer_key(key)
132
+ case key
133
+ when KeyCode::ENTER_CR, KeyCode::ENTER_LF
134
+ "\r"
135
+ when KeyCode::ESCAPE
136
+ "\e"
137
+ when KeyCode::TAB
138
+ "\t"
139
+ when KeyCode::BACKSPACE
140
+ "\x7f"
141
+ when 0..31
142
+ # Control characters - convert to the character they represent
143
+ key.chr
144
+ when KeyCode::PRINTABLE_MIN..KeyCode::PRINTABLE_MAX
145
+ key.chr(Encoding::UTF_8)
146
+ end
147
+ rescue RangeError
148
+ nil
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Represents a sequence of keys for key mapping
5
+ # Parses Vim-style notation and provides normalization for matching
6
+ class KeySequence
7
+ attr_reader :keys, :notation
8
+
9
+ # @param notation [String] Key notation string (e.g., "<Leader>gd", "<C-x><C-s>")
10
+ def initialize(notation)
11
+ @notation = notation
12
+ @keys = KeyNotationParser.parse(notation)
13
+ end
14
+
15
+ # Normalize the key sequence by expanding :leader to actual leader key
16
+ # @param leader_key [String] The actual leader key (e.g., "\\", " ")
17
+ # @return [Array<String>] Array of normalized key strings
18
+ def normalize(leader_key)
19
+ @keys.map { |k| k == :leader ? leader_key : k }
20
+ end
21
+
22
+ # Get the length of the key sequence
23
+ # @return [Integer] Number of keys in the sequence
24
+ def length
25
+ @keys.length
26
+ end
27
+
28
+ # Check if this sequence contains a leader key
29
+ # @return [Boolean]
30
+ def leader?
31
+ @keys.include?(:leader)
32
+ end
33
+
34
+ # Check if this is a single key sequence
35
+ # @return [Boolean]
36
+ def single_key?
37
+ @keys.length == 1
38
+ end
39
+
40
+ # Check equality with another KeySequence
41
+ # @param other [KeySequence]
42
+ # @return [Boolean]
43
+ def ==(other)
44
+ return false unless other.is_a?(KeySequence)
45
+
46
+ @keys == other.keys
47
+ end
48
+
49
+ alias eql? ==
50
+
51
+ # Hash for use in Hash keys
52
+ def hash
53
+ @keys.hash
54
+ end
55
+
56
+ # Convert back to notation string for display
57
+ # @return [String]
58
+ def to_s
59
+ @notation
60
+ end
61
+
62
+ # Inspect for debugging
63
+ def inspect
64
+ "#<Mui::KeySequence #{@notation.inspect} => #{@keys.inspect}>"
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Buffer for accumulating key inputs for multi-key sequence matching
5
+ # Tracks timing for timeout detection
6
+ class KeySequenceBuffer
7
+ attr_reader :last_input_time
8
+
9
+ def initialize
10
+ @buffer = []
11
+ @last_input_time = nil
12
+ end
13
+
14
+ # Push a key into the buffer
15
+ # @param key [Integer, String] Raw key input from terminal
16
+ # @return [Boolean] true if key was added, false if key was invalid
17
+ def push(key)
18
+ normalized = KeyNotationParser.normalize_input_key(key)
19
+ return false unless normalized
20
+
21
+ @buffer << normalized
22
+ @last_input_time = Time.now
23
+ true
24
+ end
25
+
26
+ # Clear the buffer
27
+ def clear
28
+ @buffer.clear
29
+ @last_input_time = nil
30
+ end
31
+
32
+ # Check if buffer is empty
33
+ # @return [Boolean]
34
+ def empty?
35
+ @buffer.empty?
36
+ end
37
+
38
+ # Get buffer length
39
+ # @return [Integer]
40
+ def length
41
+ @buffer.length
42
+ end
43
+
44
+ alias size length
45
+
46
+ # Get copy of buffer contents as array
47
+ # @return [Array<String>]
48
+ def to_a
49
+ @buffer.dup
50
+ end
51
+
52
+ # Get the first key in the buffer
53
+ # @return [String, nil]
54
+ def first
55
+ @buffer.first
56
+ end
57
+
58
+ # Remove and return the first key
59
+ # @return [String, nil]
60
+ def shift
61
+ key = @buffer.shift
62
+ @last_input_time = nil if @buffer.empty?
63
+ key
64
+ end
65
+
66
+ # Check if the buffer has timed out
67
+ # @param timeout_ms [Integer] Timeout in milliseconds
68
+ # @return [Boolean]
69
+ def timeout?(timeout_ms)
70
+ return false unless @last_input_time
71
+ return false if @buffer.empty?
72
+
73
+ elapsed_ms = (Time.now - @last_input_time) * 1000
74
+ elapsed_ms > timeout_ms
75
+ end
76
+
77
+ # Get elapsed time since last input in milliseconds
78
+ # @return [Float, nil] Elapsed time in ms, or nil if no input
79
+ def elapsed_ms
80
+ return nil unless @last_input_time
81
+
82
+ (Time.now - @last_input_time) * 1000
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Main handler for multi-key sequence processing
5
+ # Integrates buffer, matcher, and timeout handling
6
+ class KeySequenceHandler
7
+ DEFAULT_TIMEOUT_MS = 1000
8
+
9
+ # Process result types
10
+ RESULT_HANDLED = :handled # Handler executed
11
+ RESULT_PENDING = :pending # Waiting for more keys
12
+ RESULT_PASSTHROUGH = :passthrough # No match, pass key to built-in handler
13
+
14
+ attr_reader :buffer
15
+
16
+ # @param config [Config] Configuration object
17
+ def initialize(config)
18
+ @config = config
19
+ @buffer = KeySequenceBuffer.new
20
+ @keymaps = {} # { mode => { KeySequence => handler } }
21
+ @pending_handler = nil # Handler for exact match while waiting for longer
22
+ end
23
+
24
+ # Get the leader key from config
25
+ # @return [String]
26
+ def leader_key
27
+ @config.get(:leader) || "\\"
28
+ end
29
+
30
+ # Get the timeout in milliseconds
31
+ # @return [Integer]
32
+ def timeout_ms
33
+ @config.get(:timeoutlen) || DEFAULT_TIMEOUT_MS
34
+ end
35
+
36
+ # Register a key sequence mapping
37
+ # @param mode [Symbol] Mode (:normal, :insert, etc.)
38
+ # @param key_notation [String] Key notation (e.g., "<Leader>gd")
39
+ # @param handler [Proc] Handler to execute
40
+ def register(mode, key_notation, handler)
41
+ @keymaps[mode] ||= {}
42
+ sequence = KeySequence.new(key_notation)
43
+ @keymaps[mode][sequence] = handler
44
+ end
45
+
46
+ # Process an input key
47
+ # @param key [Integer, String] Raw key input
48
+ # @param mode [Symbol] Current mode
49
+ # @return [Array<Symbol, Object>] [result_type, data]
50
+ # - [:handled, handler] - Execute the handler
51
+ # - [:pending, nil] - Wait for more input
52
+ # - [:passthrough, key] - Pass key to built-in handler
53
+ def process(key, mode)
54
+ # Check timeout first - if timed out, handle before processing new key
55
+ if @buffer.timeout?(timeout_ms) && !@buffer.empty?
56
+ result = handle_timeout(mode)
57
+ # If we got a result, return it; the new key will be processed next time
58
+ return result if result[0] == RESULT_HANDLED
59
+
60
+ # If passthrough, clear buffer and continue with new key
61
+ @buffer.clear
62
+ @pending_handler = nil
63
+ end
64
+
65
+ # Add key to buffer
66
+ unless @buffer.push(key)
67
+ # Invalid key, pass through as-is
68
+ return [RESULT_PASSTHROUGH, key]
69
+ end
70
+
71
+ # Match against keymaps
72
+ matcher = KeySequenceMatcher.new(@keymaps, leader_key)
73
+ match_type, handler = matcher.match(mode, @buffer.to_a)
74
+
75
+ case match_type
76
+ when KeySequenceMatcher::MATCH_EXACT
77
+ has_longer = matcher.longer_sequences?(mode, @buffer.to_a)
78
+ if has_longer
79
+ # Exact match but longer sequences exist
80
+ # Store handler and wait for more input or timeout
81
+ @pending_handler = handler
82
+ [RESULT_PENDING, nil]
83
+ else
84
+ # Exact match, no longer sequences - execute immediately
85
+ @buffer.clear
86
+ @pending_handler = nil
87
+ [RESULT_HANDLED, handler]
88
+ end
89
+
90
+ when KeySequenceMatcher::MATCH_PARTIAL
91
+ # Could become a match, wait for more input
92
+ [RESULT_PENDING, nil]
93
+
94
+ else
95
+ # No match - pass through first key
96
+ handle_no_match
97
+ end
98
+ end
99
+
100
+ # Check for timeout and handle if needed (called from main loop)
101
+ # @param mode [Symbol] Current mode
102
+ # @return [Array<Symbol, Object>, nil] Result if timed out, nil otherwise
103
+ def check_timeout(mode)
104
+ return nil if @buffer.empty?
105
+ return nil unless @buffer.timeout?(timeout_ms)
106
+
107
+ handle_timeout(mode)
108
+ end
109
+
110
+ # Check if there are pending keys in the buffer
111
+ # @return [Boolean]
112
+ def pending?
113
+ !@buffer.empty?
114
+ end
115
+
116
+ # Clear the buffer and pending state
117
+ def clear
118
+ @buffer.clear
119
+ @pending_handler = nil
120
+ end
121
+
122
+ # Get pending key display for status line
123
+ # @return [String, nil]
124
+ def pending_keys_display
125
+ return nil if @buffer.empty?
126
+
127
+ @buffer.to_a.join
128
+ end
129
+
130
+ # Rebuild internal keymaps from config
131
+ # Called after config changes
132
+ def rebuild_keymaps
133
+ @keymaps = {}
134
+ @config.keymaps.each do |mode, mappings|
135
+ mappings.each do |key_notation, handler|
136
+ register(mode, key_notation, handler)
137
+ end
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def handle_timeout(_mode)
144
+ if @pending_handler
145
+ # We had an exact match, execute it
146
+ handler = @pending_handler
147
+ @buffer.clear
148
+ @pending_handler = nil
149
+ [RESULT_HANDLED, handler]
150
+ else
151
+ # No exact match, passthrough first key
152
+ handle_no_match
153
+ end
154
+ end
155
+
156
+ def handle_no_match
157
+ first_key = @buffer.shift
158
+ @buffer.clear
159
+ @pending_handler = nil
160
+ [RESULT_PASSTHROUGH, first_key]
161
+ end
162
+ end
163
+ end