kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,1962 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Vibe-style keymap for the built-in composer file editor.
6
+ module VibeEditorMode
7
+ VIBE_SIMPLE_MOTION_KEYS = [
8
+ "w", "e", "b", "$", "0", "^", "+", "\n", "\r", "-", "_",
9
+ "h", "\b", "\x7F", "j", "k", "l", " ", "{", "}"
10
+ ].freeze
11
+ VIBE_PAIR_TEXT_OBJECTS = {
12
+ "(" => ["(", ")"], ")" => ["(", ")"], "b" => ["(", ")"],
13
+ "[" => ["[", "]"], "]" => ["[", "]"],
14
+ "{" => ["{", "}"], "}" => ["{", "}"], "B" => ["{", "}"],
15
+ "\"" => ["\"", "\""], "'" => ["'", "'"]
16
+ }.freeze
17
+ VIBE_RUBY_BLOCK_OPENERS = %w[if unless case while until for def module class do begin].freeze
18
+ VIBE_RUBY_PATHS = %w[Gemfile Rakefile Guardfile Capfile Vagrantfile].freeze
19
+ VIBE_RUBY_EXTENSIONS = %w[.rb .rake .gemspec].freeze
20
+
21
+ VibeOperatorTarget = Struct.new(:type, :start_index, :end_index, :replacement_text, :replacement_cursor_offset, keyword_init: true) do
22
+ def characterwise?
23
+ type == :characterwise
24
+ end
25
+
26
+ def change_replacement_text
27
+ replacement_text.to_s
28
+ end
29
+
30
+ def change_cursor_index
31
+ start_index + replacement_cursor_offset.to_i
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def handle_vibe_key(key)
38
+ csi_result = handle_vibe_csi_u_key(key)
39
+ return csi_result unless csi_result == false
40
+
41
+ tab_result = handle_tab_key_binding(key)
42
+ return tab_result unless tab_result == false
43
+
44
+ return vibe_stop_macro_recording if key == "q" && @editor_state.vibe_recording_macro && !%w[insert replace command].include?(@editor_state.vibe_mode)
45
+ vibe_record_macro_key(key)
46
+ return vibe_begin_visual_mode("visual_block") if key == "\x16" && @editor_state.vibe_mode == "normal"
47
+ return handle_vibe_repeat_change if key == "." && @editor_state.vibe_mode == "normal"
48
+ return handle_vibe_search_key(key) if editor_search_active?
49
+ return handle_vibe_command_key(key) if @editor_state.vibe_mode == "command"
50
+ return handle_vibe_insert_key(key) if @editor_state.vibe_mode == "insert"
51
+ return handle_vibe_replace_key(key) if @editor_state.vibe_mode == "replace"
52
+ return handle_vibe_visual_key(key) if vibe_visual_mode?
53
+
54
+ handle_vibe_normal_key(key)
55
+ end
56
+
57
+ def handle_vibe_csi_u_key(key)
58
+ sequence = parse_csi_u_key(key)
59
+ return false unless sequence
60
+
61
+ code = sequence[:code]
62
+ modifier = sequence[:modifier]
63
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
64
+ normalized_code = code.to_i.chr.downcase.ord rescue code
65
+ if ctrl_modifier?(modifier) && code == 13 && %w[insert replace].include?(@editor_state.vibe_mode)
66
+ return vibe_record_undo { editor_insert_endwise_modifier_newline }
67
+ end
68
+
69
+ if @editor_state.vibe_mode == "normal" && ctrl_modifier?(modifier)
70
+ ctrl_result = handle_vibe_normal_ctrl_key(normalized_code)
71
+ return ctrl_result unless ctrl_result == false
72
+ end
73
+
74
+ logical_key = vibe_csi_u_logical_key(sequence)
75
+ return handle_vibe_key(logical_key) if logical_key
76
+ return false unless code == 27 || (ctrl_modifier?(modifier) && normalized_code == 99)
77
+
78
+ return editor_search_cancel if editor_search_active?
79
+
80
+ @editor_state.vibe_command = ""
81
+ @editor_state.vibe_pending = ""
82
+ @editor_state.clear_selection
83
+ vibe_return_to_normal
84
+ end
85
+
86
+ def handle_vibe_normal_ctrl_key(normalized_code)
87
+ case normalized_code
88
+ when 104
89
+ @editor_state.move_line_first_non_blank
90
+ when 106
91
+ @editor_state.move_indentation_down
92
+ when 107
93
+ @editor_state.move_indentation_up
94
+ when 108
95
+ @editor_state.move_line_end
96
+ when 118
97
+ vibe_begin_visual_mode("visual_block")
98
+ else
99
+ false
100
+ end
101
+ end
102
+
103
+ def vibe_csi_u_logical_key(sequence)
104
+ code = sequence[:code]
105
+ text = csi_u_text(sequence)
106
+ normalized_code = code.to_i.chr.downcase.ord rescue code
107
+ return "\n" if code == 13
108
+ return "\x7F" if [8, 127].include?(code)
109
+ return (normalized_code - 96).chr if ctrl_modifier?(sequence[:modifier]) && normalized_code.between?(97, 122)
110
+ return text if text.length == 1 && printable_key?(text)
111
+ return code.chr(Encoding::UTF_8) if sequence[:modifier] == 1 && code.between?(32, 126)
112
+
113
+ nil
114
+ end
115
+
116
+ def handle_vibe_search_key(key)
117
+ case key
118
+ when "\n", "\r"
119
+ editor_search_confirm
120
+ when "\b", "\x7F"
121
+ editor_search_delete_character
122
+ when "\e", "\x03"
123
+ editor_search_cancel
124
+ else
125
+ editor_search_append(key) if printable_key?(key)
126
+ end
127
+ true
128
+ end
129
+
130
+ def handle_vibe_insert_key(key)
131
+ return if handle_editor_bracketed_paste_key(key)
132
+
133
+ vibe_record_insert_change_key(key)
134
+ tab_result = handle_editor_tab_key(key) { |direction| vibe_record_undo { direction == :forward ? editor_insert_tab : editor_outdent_tab } }
135
+ return tab_result unless tab_result == false
136
+
137
+ case key
138
+ when "\e", "\x03", :escape
139
+ vibe_return_to_normal
140
+ when "\b", "\x7F"
141
+ vibe_record_undo { editor_delete_before_cursor }
142
+ when "\n", "\r"
143
+ vibe_record_undo { editor_insert_newline }
144
+ else
145
+ readline_result = handle_vibe_insert_readline_key(key)
146
+ return readline_result unless readline_result == false || readline_result.nil?
147
+
148
+ key_name = key_name_for(key)
149
+ named_result = handle_vibe_insert_named_key(key_name) if key_name
150
+ return named_result unless named_result == false || named_result.nil?
151
+
152
+ vibe_record_undo { editor_insert_printable(key) } if printable_key?(key)
153
+ end
154
+ end
155
+
156
+ def handle_vibe_insert_readline_key(key)
157
+ csi_result = handle_vibe_insert_readline_csi_u_key(key)
158
+ return csi_result unless csi_result == false
159
+
160
+ handle_vibe_insert_readline_ansi_key(key)
161
+ end
162
+
163
+ def handle_vibe_insert_readline_csi_u_key(key)
164
+ sequence = parse_csi_u_key(key)
165
+ return false unless sequence
166
+
167
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
168
+ modifier = sequence[:modifier]
169
+ normalized_code = sequence[:code].to_i.chr.downcase.ord rescue sequence[:code]
170
+ if ctrl_modifier?(modifier)
171
+ return handle_vibe_insert_readline_ctrl_key(normalized_code)
172
+ elsif alt_modifier?(modifier)
173
+ return handle_vibe_insert_readline_alt_key(normalized_code)
174
+ end
175
+
176
+ false
177
+ end
178
+
179
+ def handle_vibe_insert_readline_ansi_key(key)
180
+ case key
181
+ when "\x01"
182
+ @editor_state.move_line_start
183
+ when "\x02"
184
+ @editor_state.move_left
185
+ when "\x04"
186
+ vibe_record_undo { @editor_state.delete_at_cursor }
187
+ when "\x05"
188
+ @editor_state.move_line_end
189
+ when "\x06"
190
+ @editor_state.move_right
191
+ when "\x0B"
192
+ vibe_record_undo { @editor_state.kill_line_after_cursor }
193
+ when "\x15"
194
+ vibe_record_undo { @editor_state.kill_line_before_cursor }
195
+ when "\x17"
196
+ vibe_record_undo { @editor_state.delete_word_before_cursor }
197
+ when "\x19"
198
+ vibe_record_undo { @editor_state.yank_kill_buffer }
199
+ when "\e[D", "\eOD"
200
+ @editor_state.move_left
201
+ when "\e[C", "\eOC"
202
+ @editor_state.move_right
203
+ when "\e[H", "\eOH", "\e[1~", "\e[7~"
204
+ @editor_state.move_line_start
205
+ when "\e[F", "\eOF", "\e[4~", "\e[8~"
206
+ @editor_state.move_line_end
207
+ when "\e[3~"
208
+ vibe_record_undo { @editor_state.delete_at_cursor }
209
+ when "\eb", "\eB"
210
+ @editor_state.move_to_previous_word
211
+ when "\ef", "\eF"
212
+ @editor_state.move_to_next_word
213
+ when "\ed", "\eD"
214
+ vibe_record_undo { @editor_state.delete_word_after_cursor }
215
+ when "\e\b", "\e\x7F"
216
+ vibe_record_undo { @editor_state.delete_word_before_cursor }
217
+ else
218
+ handle_vibe_insert_modified_ansi_key(key)
219
+ end
220
+ end
221
+
222
+ def handle_vibe_insert_readline_ctrl_key(normalized_code)
223
+ case normalized_code
224
+ when 97
225
+ @editor_state.move_line_start
226
+ when 98
227
+ @editor_state.move_left
228
+ when 100
229
+ vibe_record_undo { @editor_state.delete_at_cursor }
230
+ when 101
231
+ @editor_state.move_line_end
232
+ when 102
233
+ @editor_state.move_right
234
+ when 107
235
+ vibe_record_undo { @editor_state.kill_line_after_cursor }
236
+ when 117
237
+ vibe_record_undo { @editor_state.kill_line_before_cursor }
238
+ when 119
239
+ vibe_record_undo { @editor_state.delete_word_before_cursor }
240
+ when 121
241
+ vibe_record_undo { @editor_state.yank_kill_buffer }
242
+ else
243
+ false
244
+ end
245
+ end
246
+
247
+ def handle_vibe_insert_readline_alt_key(normalized_code)
248
+ case normalized_code
249
+ when 98
250
+ @editor_state.move_to_previous_word
251
+ when 100
252
+ vibe_record_undo { @editor_state.delete_word_after_cursor }
253
+ when 102
254
+ @editor_state.move_to_next_word
255
+ else
256
+ false
257
+ end
258
+ end
259
+
260
+ def handle_vibe_insert_modified_ansi_key(key)
261
+ sequence = parse_modified_ansi_key(key)
262
+ return false unless sequence
263
+
264
+ case sequence[:type]
265
+ when :cursor
266
+ return false unless alt_modifier?(sequence[:modifier])
267
+
268
+ case sequence[:final]
269
+ when "C"
270
+ @editor_state.move_to_next_word
271
+ when "D"
272
+ @editor_state.move_to_previous_word
273
+ when "F"
274
+ @editor_state.move_line_end
275
+ when "H"
276
+ @editor_state.move_line_start
277
+ else
278
+ false
279
+ end
280
+ when :delete
281
+ return false unless alt_modifier?(sequence[:modifier])
282
+
283
+ vibe_record_undo { @editor_state.delete_word_after_cursor }
284
+ else
285
+ false
286
+ end
287
+ end
288
+
289
+ def handle_vibe_insert_named_key(key_name)
290
+ case key_name
291
+ when :escape
292
+ vibe_return_to_normal
293
+ when :return, :enter
294
+ vibe_record_undo { editor_insert_newline }
295
+ when :backspace
296
+ vibe_record_undo { editor_delete_before_cursor }
297
+ when :delete
298
+ vibe_record_undo { @editor_state.delete_at_cursor }
299
+ when :left
300
+ @editor_state.move_left
301
+ when :right
302
+ @editor_state.move_right
303
+ when :up
304
+ editor_move_up
305
+ when :down
306
+ editor_move_down
307
+ else
308
+ false
309
+ end
310
+ end
311
+
312
+ def handle_vibe_replace_key(key)
313
+ return if handle_editor_bracketed_paste_key(key)
314
+
315
+ vibe_record_insert_change_key(key)
316
+ tab_result = handle_editor_tab_key(key) { |direction| vibe_record_undo { direction == :forward ? editor_insert_tab : editor_outdent_tab } }
317
+ return tab_result unless tab_result == false
318
+
319
+ case key
320
+ when "\e", "\x03", :escape
321
+ vibe_return_to_normal
322
+ when "\b", "\x7F"
323
+ vibe_record_undo { editor_delete_before_cursor }
324
+ when "\n", "\r"
325
+ vibe_record_undo { editor_insert_newline }
326
+ else
327
+ key_name = key_name_for(key)
328
+ named_result = handle_vibe_insert_named_key(key_name) if key_name
329
+ return named_result unless named_result == false || named_result.nil?
330
+
331
+ vibe_record_undo { vibe_replace_character(key) } if printable_key?(key)
332
+ end
333
+ end
334
+
335
+ def vibe_replace_character(key)
336
+ @editor_state.delete_at_cursor
337
+ editor_insert_printable(key)
338
+ end
339
+
340
+ def handle_vibe_command_key(key)
341
+ case key
342
+ when "\e", "\x03", :escape
343
+ @editor_state.vibe_command = ""
344
+ vibe_return_to_normal
345
+ when "\b", "\x7F"
346
+ @editor_state.vibe_command = @editor_state.vibe_command[0...-1].to_s
347
+ @editor_state.status = ":#{@editor_state.vibe_command}"
348
+ when "\n", "\r"
349
+ execute_vibe_command(@editor_state.vibe_command)
350
+ else
351
+ if printable_key?(key)
352
+ @editor_state.vibe_command = @editor_state.vibe_command.to_s + key
353
+ @editor_state.status = ":#{@editor_state.vibe_command}"
354
+ end
355
+ end
356
+ true
357
+ end
358
+
359
+ def execute_vibe_command(command)
360
+ command = command.to_s.strip
361
+ @editor_state.vibe_mode = "normal"
362
+ @editor_state.vibe_command = ""
363
+ case command
364
+ when "w"
365
+ save_editor
366
+ when "q"
367
+ vibe_quit_editor
368
+ when "q!"
369
+ close_editor
370
+ when "wq"
371
+ save_editor && close_editor
372
+ when "x"
373
+ save_editor if @editor_state&.dirty?
374
+ close_editor if @editor_state
375
+ when /\A(?:(%|\d+,\d+))?s\/([^\/]*)\/([^\/]*)\/(g?)\z/
376
+ vibe_substitute_command(Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3), global: Regexp.last_match(4) == "g")
377
+ when /\A\d+\z/
378
+ @editor_state.set_cursor_line_and_column(command.to_i - 1, 0)
379
+ @editor_state.status = "Line #{command}"
380
+ else
381
+ @editor_state.status = "Unknown command: #{command}"
382
+ end
383
+ true
384
+ end
385
+
386
+ def vibe_substitute_command(range, pattern, replacement, global: false)
387
+ if pattern.empty?
388
+ @editor_state.status = "Substitute pattern required"
389
+ return false
390
+ end
391
+
392
+ start_line = 0
393
+ end_line = @editor_state.lines.length - 1
394
+ if range&.include?(",")
395
+ start_line, end_line = range.split(",", 2).map { |value| value.to_i - 1 }
396
+ end
397
+ start_line = [[start_line, 0].max, @editor_state.lines.length - 1].min
398
+ end_line = [[end_line, 0].max, @editor_state.lines.length - 1].min
399
+ start_line, end_line = [start_line, end_line].minmax
400
+ start_index = @editor_state.line_range(start_line)[0]
401
+ end_index = @editor_state.line_range(end_line)[1]
402
+ text = @editor_state.buffer[start_index...end_index].to_s
403
+ changed = global ? text.gsub(pattern, replacement) : text.lines.map { |line| line.sub(pattern, replacement) }.join
404
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, changed) }
405
+ @editor_state.status = "Substituted"
406
+ true
407
+ end
408
+
409
+ def vibe_quit_editor
410
+ return close_editor unless @editor_state.dirty?
411
+
412
+ @editor_state.status = "No write since last change (:q! overrides)"
413
+ true
414
+ end
415
+
416
+ def handle_vibe_normal_key(key)
417
+ if key == "\e" || key == "\x03"
418
+ @editor_state.vibe_pending = ""
419
+ vibe_return_to_normal
420
+ return true
421
+ end
422
+
423
+ key_name = key_name_for(key)
424
+ named_result = handle_vibe_named_key(key_name) if key_name
425
+ return named_result unless named_result == false || named_result.nil?
426
+ return false unless key.is_a?(String)
427
+ return true unless printable_key?(key) || vibe_normal_control_key?(key)
428
+
429
+ pending = @editor_state.vibe_pending.to_s + key
430
+ return vibe_store_pending_command(pending) if vibe_waiting_for_more?(pending)
431
+
432
+ @editor_state.vibe_pending = ""
433
+ execute_vibe_normal_command(pending)
434
+ true
435
+ end
436
+
437
+ def vibe_store_pending_command(command)
438
+ @editor_state.vibe_pending = command
439
+ @editor_state.status = "#{@editor_state.vibe_mode.upcase.tr("_", " ")} #{command}"
440
+ true
441
+ end
442
+
443
+ def handle_vibe_named_key(key_name)
444
+ case key_name
445
+ when :escape
446
+ @editor_state.vibe_pending = ""
447
+ vibe_return_to_normal
448
+ when :left
449
+ @editor_state.move_left
450
+ when :right
451
+ @editor_state.move_right
452
+ when :up
453
+ editor_move_up
454
+ when :down
455
+ editor_move_down
456
+ when :backspace
457
+ @editor_state.move_left
458
+ when :return, :enter
459
+ vibe_move_to_relative_line_first_non_blank(1)
460
+ when :ctrl_b
461
+ @editor_state.page_up(editor_page_rows)
462
+ when :ctrl_f
463
+ @editor_state.page_down(editor_page_rows)
464
+ when :ctrl_d
465
+ @editor_state.page_down(vibe_half_page_rows)
466
+ when :ctrl_u
467
+ @editor_state.page_up(vibe_half_page_rows)
468
+ when :ctrl_e
469
+ vibe_scroll_down
470
+ when :ctrl_y
471
+ vibe_scroll_up
472
+ when :ctrl_r
473
+ @editor_state.redo
474
+ else
475
+ false
476
+ end
477
+ end
478
+
479
+ def vibe_normal_control_key?(key)
480
+ ["\n", "\r", "\b", "\x7F", "\x02", "\x04", "\x05", "\x06", "\x12", "\x15", "\x19"].include?(key)
481
+ end
482
+
483
+ def vibe_visual_mode?
484
+ %w[visual visual_line visual_block].include?(@editor_state.vibe_mode)
485
+ end
486
+
487
+ def vibe_return_to_normal
488
+ vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
489
+ @editor_state.vibe_mode = "normal"
490
+ @editor_state.status = "NORMAL · i insert · :w save · :q quit"
491
+ true
492
+ end
493
+
494
+ def vibe_cancel_visual_mode
495
+ @editor_state.vibe_pending = ""
496
+ vibe_remember_visual_selection
497
+ @editor_state.clear_selection
498
+ vibe_return_to_normal
499
+ end
500
+
501
+ def vibe_remember_visual_selection
502
+ return unless @editor_state.selection_active?
503
+
504
+ @editor_state.vibe_last_visual_selection = {
505
+ mode: @editor_state.vibe_mode,
506
+ anchor: @editor_state.selection_anchor,
507
+ cursor: @editor_state.cursor
508
+ }
509
+ end
510
+
511
+ def vibe_waiting_for_more?(command)
512
+ return true if command.match?(/\A\d+\z/) && command != "0"
513
+ return true if command.match?(/\A\d*g\z/)
514
+ return true if command.match?(/\A\d*z\z/)
515
+ return true if command.match?(/\A\d*[cdy]\d*\z/)
516
+ return true if command.match?(/\A\d*[cdy]\d*[ai]\z/)
517
+ return true if command.match?(/\A\d*[cdy]\d*[fFtT]\z/)
518
+ return true if command.match?(/\A\d*[fFtT]\z/)
519
+ return true if command.match?(/\A\d*r\z/)
520
+ return true if command.match?(/\Am\z/)
521
+ return true if command.match?(/\A"[a-z]?\z/)
522
+ return true if command.match?(/\A"[a-z][cdy]\z/)
523
+ return true if command.match?(/\A"[a-z][cdy][ai]\z/)
524
+ return true if command.match?(/\Aq\z/)
525
+ return true if command.match?(/\A@\z/)
526
+ return true if command.match?(/\A[\[\]]\z/)
527
+ return true if command.match?(/\A['`]\z/)
528
+
529
+ false
530
+ end
531
+
532
+ def execute_vibe_normal_command(command)
533
+ original_command = command
534
+ register = nil
535
+ if (register_match = command.match(/\A"([a-z])(.*)\z/))
536
+ register = register_match[1]
537
+ command = register_match[2]
538
+ end
539
+ @vibe_active_register = register
540
+ count, body = vibe_count_and_body(command)
541
+ count = 1 if count.zero?
542
+ case body
543
+ when *VIBE_SIMPLE_MOTION_KEYS
544
+ vibe_apply_cursor_motion(body, count)
545
+ when "gg"
546
+ @editor_state.move_file_start
547
+ when "gv"
548
+ vibe_restore_visual_selection
549
+ when "]m"
550
+ vibe_jump_ruby_method(:forward)
551
+ when "[m"
552
+ vibe_jump_ruby_method(:backward)
553
+ when /\Aq(.+)\z/
554
+ vibe_start_macro_recording(Regexp.last_match(1))
555
+ when "@@"
556
+ vibe_play_macro(@editor_state.vibe_last_macro)
557
+ when /\A@(.+)\z/
558
+ vibe_play_macro(Regexp.last_match(1))
559
+ when /\Am(.+)\z/
560
+ vibe_set_mark(Regexp.last_match(1))
561
+ when /\A'(.+)\z/
562
+ vibe_jump_to_mark(Regexp.last_match(1), linewise: true)
563
+ when /\A`(.+)\z/
564
+ vibe_jump_to_mark(Regexp.last_match(1), linewise: false)
565
+ when "G"
566
+ line = command.match?(/\A\d+G\z/) ? count - 1 : @editor_state.lines.length - 1
567
+ @editor_state.set_cursor_line_and_column(line, 0)
568
+ when "zz"
569
+ vibe_position_cursor_line(:center)
570
+ when "zt"
571
+ vibe_position_cursor_line(:top)
572
+ when "zb"
573
+ vibe_position_cursor_line(:bottom)
574
+ when "H"
575
+ vibe_move_to_screen_line(count - 1)
576
+ when "M"
577
+ vibe_move_to_screen_line(editor_page_rows / 2)
578
+ when "L"
579
+ vibe_move_to_screen_line(editor_page_rows - count)
580
+ when "\x06"
581
+ @editor_state.page_down(editor_page_rows)
582
+ when "\x02"
583
+ @editor_state.page_up(editor_page_rows)
584
+ when "\x04"
585
+ @editor_state.page_down(vibe_half_page_rows)
586
+ when "\x15"
587
+ @editor_state.page_up(vibe_half_page_rows)
588
+ when "\x05"
589
+ vibe_scroll_down
590
+ when "\x19"
591
+ vibe_scroll_up
592
+ when "\x12"
593
+ @editor_state.redo
594
+ when "i"
595
+ vibe_enter_insert_mode(command)
596
+ when "I"
597
+ @editor_state.move_line_first_non_blank
598
+ vibe_enter_insert_mode(command)
599
+ when "a"
600
+ @editor_state.move_right
601
+ vibe_enter_insert_mode(command)
602
+ when "A"
603
+ @editor_state.move_line_end
604
+ vibe_enter_insert_mode(command)
605
+ when "C"
606
+ vibe_change_to_line_end(command)
607
+ when "D"
608
+ vibe_delete_to_line_end(command)
609
+ when "R"
610
+ @editor_state.vibe_mode = "replace"
611
+ @editor_state.status = "REPLACE · Esc normal"
612
+ vibe_begin_change_recording(command)
613
+ when "s"
614
+ vibe_substitute_characters(count, command)
615
+ when "S"
616
+ vibe_change_lines(count, command)
617
+ when "J"
618
+ vibe_join_lines(count, command)
619
+ when "n"
620
+ editor_search_repeat
621
+ when "N"
622
+ editor_search_repeat(vibe_opposite_search_direction)
623
+ when "*"
624
+ editor_search_word_under_cursor(:forward)
625
+ when "#"
626
+ editor_search_word_under_cursor(:backward)
627
+ when "U"
628
+ vibe_restore_current_line
629
+ when "%"
630
+ vibe_jump_to_matching_pair
631
+ when /^([fFtT])(.?)$/
632
+ vibe_find_character(Regexp.last_match(1), Regexp.last_match(2), count)
633
+ when ";"
634
+ vibe_repeat_find_character
635
+ when ","
636
+ vibe_repeat_find_character(reverse: true)
637
+ when /^r(.?)$/
638
+ vibe_replace_single_character(Regexp.last_match(1), count, command)
639
+ when "v"
640
+ vibe_begin_visual_mode("visual")
641
+ when "V"
642
+ vibe_begin_visual_mode("visual_line")
643
+ when "o"
644
+ vibe_open_line_below
645
+ when "O"
646
+ vibe_open_line_above
647
+ when "x"
648
+ vibe_record_undo { count.times { @editor_state.delete_at_cursor } }
649
+ vibe_remember_change(command)
650
+ when "X"
651
+ vibe_record_undo { count.times { @editor_state.delete_before_cursor } }
652
+ vibe_remember_change(command)
653
+ when "dd"
654
+ vibe_delete_lines(count)
655
+ vibe_store_active_register
656
+ vibe_remember_change(command)
657
+ when "cc"
658
+ vibe_change_lines(count, command)
659
+ vibe_store_active_register
660
+ when "yy"
661
+ vibe_yank_lines(count)
662
+ vibe_store_active_register
663
+ when "p"
664
+ vibe_record_undo { @editor_state.insert(vibe_active_register_text) }
665
+ vibe_remember_change(original_command)
666
+ when "P"
667
+ vibe_paste_before(original_command)
668
+ when "u"
669
+ @editor_state.undo
670
+ when ":"
671
+ @editor_state.vibe_mode = "command"
672
+ @editor_state.vibe_command = ""
673
+ @editor_state.status = ":"
674
+ when "/"
675
+ editor_search_begin
676
+ when "?"
677
+ editor_search_begin(:backward)
678
+ else
679
+ if body.start_with?("d") || body.start_with?("y") || body.start_with?("c")
680
+ vibe_operator_motion(body[0], body[1..], count, command)
681
+ elsif body.start_with?("z") && body.length > 1
682
+ execute_vibe_normal_command(body[1..])
683
+ else
684
+ @editor_state.status = "Unknown command: #{command}"
685
+ end
686
+ end
687
+ ensure
688
+ @vibe_active_register = nil
689
+ end
690
+
691
+ def handle_vibe_visual_key(key)
692
+ key_name = key_name_for(key)
693
+ return handle_vibe_visual_named_key(key_name) if key_name
694
+ if key == "\e" || key == "\x03"
695
+ @editor_state.vibe_pending = ""
696
+ vibe_cancel_visual_mode
697
+ return true
698
+ end
699
+ return true unless printable_key?(key)
700
+
701
+ command = @editor_state.vibe_pending.to_s + key
702
+ return vibe_store_pending_command(command) if vibe_visual_waiting_for_more?(command)
703
+
704
+ @editor_state.vibe_pending = ""
705
+ execute_vibe_visual_command(command)
706
+ true
707
+ end
708
+
709
+ def execute_vibe_visual_command(command)
710
+ count, body = vibe_count_and_body(command)
711
+ count = 1 if count.zero?
712
+
713
+ case body
714
+ when *EditorAutoClosePairs::AUTO_CLOSE_OPENERS
715
+ vibe_record_undo { editor_insert_printable(body) }
716
+ vibe_return_to_normal
717
+ when "y"
718
+ vibe_yank_visual_selection
719
+ when "d", "x"
720
+ vibe_delete_visual_selection
721
+ when "c"
722
+ vibe_change_visual_selection
723
+ when "p"
724
+ vibe_paste_visual_selection
725
+ when "I"
726
+ vibe_begin_visual_block_insert(:before)
727
+ when "A"
728
+ vibe_begin_visual_block_insert(:after)
729
+ when ">"
730
+ vibe_indent_visual_selection(:right)
731
+ when "<"
732
+ vibe_indent_visual_selection(:left)
733
+ when "J"
734
+ vibe_join_visual_selection
735
+ when "~"
736
+ vibe_transform_visual_selection(:swapcase)
737
+ when "u"
738
+ vibe_transform_visual_selection(:downcase)
739
+ when "U"
740
+ vibe_transform_visual_selection(:upcase)
741
+ when "/"
742
+ editor_search_begin
743
+ when "?"
744
+ editor_search_begin(:backward)
745
+ when "n"
746
+ editor_search_repeat
747
+ when "N"
748
+ editor_search_repeat(vibe_opposite_search_direction)
749
+ when "o"
750
+ vibe_switch_visual_selection_end
751
+ when "G"
752
+ vibe_visual_goto_line(command.match?(/\A\d+G\z/) ? count : nil)
753
+ when "gg"
754
+ vibe_visual_goto_line(command.match?(/\A\d+gg\z/) ? count : 1)
755
+ when "%"
756
+ vibe_jump_to_matching_pair
757
+ when /^([fFtT])(.?)$/
758
+ vibe_find_character(Regexp.last_match(1), Regexp.last_match(2), count)
759
+ when /\A[ai].\z/
760
+ vibe_select_text_object(body)
761
+ when ";"
762
+ vibe_repeat_find_character
763
+ when ","
764
+ vibe_repeat_find_character(reverse: true)
765
+ else
766
+ vibe_move_visual_selection(body, count)
767
+ end
768
+ end
769
+
770
+ def vibe_visual_waiting_for_more?(command)
771
+ return true if command.match?(/\A[1-9]\d*\z/)
772
+ return true if command.match?(/\A\d*g\z/)
773
+ return true if command.match?(/\A\d*[fFtT]\z/)
774
+ return true if command.match?(/\A\d*[ai]\z/)
775
+
776
+ false
777
+ end
778
+
779
+ def handle_vibe_visual_named_key(key_name)
780
+ case key_name
781
+ when :escape
782
+ vibe_cancel_visual_mode
783
+ when :left
784
+ @editor_state.move_left
785
+ when :right
786
+ @editor_state.move_right
787
+ when :up
788
+ editor_move_up
789
+ when :down
790
+ editor_move_down
791
+ else
792
+ false
793
+ end
794
+ end
795
+
796
+ def vibe_switch_visual_selection_end
797
+ @editor_state.selection_anchor, @editor_state.cursor = @editor_state.cursor, @editor_state.selection_anchor
798
+ true
799
+ end
800
+
801
+ def vibe_jump_ruby_method(direction)
802
+ unless vibe_ruby_file?
803
+ @editor_state.status = "Ruby navigation requires Ruby file"
804
+ return false
805
+ end
806
+
807
+ line, = @editor_state.cursor_line_and_column
808
+ candidates = @editor_state.lines.each_with_index.select { |source, _index| source.match?(/\A\s*def\b/) }.map(&:last)
809
+ target = if direction == :forward
810
+ candidates.find { |index| index > line }
811
+ else
812
+ candidates.reverse.find { |index| index < line }
813
+ end
814
+ unless target
815
+ @editor_state.status = "Ruby method not found"
816
+ return false
817
+ end
818
+
819
+ @editor_state.move_to_line_first_non_blank(target)
820
+ true
821
+ end
822
+
823
+ def vibe_start_macro_recording(name)
824
+ @editor_state.vibe_recording_macro = name
825
+ @editor_state.vibe_macros[name] = []
826
+ @editor_state.status = "Recording macro #{name}"
827
+ true
828
+ end
829
+
830
+ def vibe_stop_macro_recording
831
+ name = @editor_state.vibe_recording_macro
832
+ @editor_state.vibe_pending = ""
833
+ @editor_state.vibe_recording_macro = nil
834
+ @editor_state.vibe_last_macro = name
835
+ @editor_state.status = "Recorded macro #{name}"
836
+ true
837
+ end
838
+
839
+ def vibe_record_macro_key(key)
840
+ name = @editor_state.vibe_recording_macro
841
+ return if !name || @vibe_replaying_macro
842
+
843
+ @editor_state.vibe_macros[name] << key
844
+ end
845
+
846
+ def vibe_play_macro(name)
847
+ macro = @editor_state.vibe_macros[name]
848
+ unless macro
849
+ @editor_state.status = "Macro not set: #{name}"
850
+ return false
851
+ end
852
+
853
+ @editor_state.vibe_last_macro = name
854
+ @vibe_replaying_macro = true
855
+ macro.each { |key| handle_vibe_key(key) }
856
+ @editor_state.status = "Played macro #{name}"
857
+ true
858
+ ensure
859
+ @vibe_replaying_macro = false
860
+ end
861
+
862
+ def vibe_set_mark(name)
863
+ @editor_state.vibe_marks[name] = { cursor: @editor_state.cursor }
864
+ @editor_state.status = "Set mark #{name}"
865
+ true
866
+ end
867
+
868
+ def vibe_jump_to_mark(name, linewise:)
869
+ mark = @editor_state.vibe_marks[name]
870
+ unless mark
871
+ @editor_state.status = "Mark not set: #{name}"
872
+ return false
873
+ end
874
+
875
+ @editor_state.cursor = [[mark[:cursor], 0].max, @editor_state.buffer.length].min
876
+ @editor_state.move_line_first_non_blank if linewise
877
+ true
878
+ end
879
+
880
+ def vibe_restore_visual_selection
881
+ selection = @editor_state.vibe_last_visual_selection
882
+ unless selection
883
+ @editor_state.status = "No visual selection to restore"
884
+ return false
885
+ end
886
+
887
+ @editor_state.vibe_mode = selection[:mode]
888
+ @editor_state.selection_anchor = [[selection[:anchor], 0].max, @editor_state.buffer.length].min
889
+ @editor_state.cursor = [[selection[:cursor], 0].max, @editor_state.buffer.length].min
890
+ @editor_state.status = case @editor_state.vibe_mode
891
+ when "visual_line" then "VISUAL LINE"
892
+ when "visual_block" then "VISUAL BLOCK"
893
+ else "VISUAL"
894
+ end
895
+ true
896
+ end
897
+
898
+ def vibe_begin_visual_mode(mode)
899
+ @editor_state.clear_selection
900
+ @editor_state.selection_anchor = @editor_state.cursor
901
+ @editor_state.vibe_mode = mode
902
+ @editor_state.status = case mode
903
+ when "visual_line" then "VISUAL LINE"
904
+ when "visual_block" then "VISUAL BLOCK"
905
+ else "VISUAL"
906
+ end
907
+ true
908
+ end
909
+
910
+ def vibe_select_text_object(text_object)
911
+ target = vibe_text_object_target(text_object)
912
+ return false unless target
913
+
914
+ @editor_state.selection_anchor = target.start_index
915
+ @editor_state.cursor = [target.end_index - 1, target.start_index].max
916
+ true
917
+ end
918
+
919
+ def vibe_visual_goto_line(line_number = nil)
920
+ line = line_number ? line_number - 1 : @editor_state.lines.length - 1
921
+ @editor_state.set_cursor_line_and_column(line, 0)
922
+ true
923
+ end
924
+
925
+ def vibe_move_visual_selection(motion, count = 1)
926
+ vibe_apply_cursor_motion(motion, count)
927
+ end
928
+
929
+ def vibe_visual_range
930
+ @editor_state.selection_range
931
+ end
932
+
933
+ def vibe_yank_visual_selection
934
+ if @editor_state.vibe_mode == "visual_block"
935
+ @editor_state.kill_buffer = @editor_state.selected_text
936
+ @editor_state.status = "Yanked selection"
937
+ vibe_cancel_visual_mode
938
+ return true
939
+ end
940
+
941
+ range = vibe_visual_range
942
+ return false unless range
943
+
944
+ vibe_copy_range(range[0], range[1], "Yanked selection")
945
+ vibe_cancel_visual_mode
946
+ end
947
+
948
+ def vibe_delete_visual_selection
949
+ if @editor_state.vibe_mode == "visual_block"
950
+ @editor_state.kill_buffer = @editor_state.selected_text
951
+ vibe_record_undo { @editor_state.selection_ranges.reverse_each { |range| @editor_state.replace_range(range[0], range[1], "") } }
952
+ vibe_cancel_visual_mode
953
+ return true
954
+ end
955
+
956
+ range = vibe_visual_range
957
+ return false unless range
958
+
959
+ @editor_state.copy_range(range[0], range[1])
960
+ vibe_record_undo { @editor_state.replace_range(range[0], range[1], "") }
961
+ vibe_cancel_visual_mode
962
+ end
963
+
964
+ def vibe_change_visual_selection
965
+ range = vibe_visual_range
966
+ return false unless range
967
+
968
+ @editor_state.copy_range(range[0], range[1])
969
+ vibe_record_undo { @editor_state.replace_range(range[0], range[1], "") }
970
+ @editor_state.clear_selection
971
+ @editor_state.vibe_mode = "insert"
972
+ @editor_state.status = "INSERT · Esc normal"
973
+ end
974
+
975
+ def vibe_paste_visual_selection
976
+ range = vibe_visual_range
977
+ return false unless range
978
+
979
+ text = @editor_state.kill_buffer.to_s
980
+ vibe_record_undo { @editor_state.replace_range(range[0], range[1], text) }
981
+ vibe_cancel_visual_mode
982
+ end
983
+
984
+ def vibe_begin_visual_block_insert(position)
985
+ return vibe_move_visual_selection(position == :before ? "I" : "A") unless @editor_state.vibe_mode == "visual_block"
986
+
987
+ anchor_line, anchor_column = @editor_state.cursor_line_and_column_for(@editor_state.selection_anchor)
988
+ cursor_line, cursor_column = @editor_state.cursor_line_and_column
989
+ start_line, end_line = [anchor_line, cursor_line].minmax
990
+ start_column, end_column = [anchor_column, cursor_column].minmax
991
+ column = position == :before ? start_column : end_column + 1
992
+ @editor_state.vibe_visual_block_insert = { start_line: start_line, end_line: end_line, column: column }
993
+ @editor_state.clear_selection
994
+ @editor_state.set_cursor_line_and_column(start_line, column)
995
+ @editor_state.vibe_visual_block_insert[:start_index] = @editor_state.cursor
996
+ @editor_state.vibe_mode = "insert"
997
+ @editor_state.status = "INSERT · Esc normal"
998
+ true
999
+ end
1000
+
1001
+ def vibe_apply_visual_block_insert
1002
+ block = @editor_state.vibe_visual_block_insert
1003
+ @editor_state.vibe_visual_block_insert = nil
1004
+ return unless block
1005
+
1006
+ inserted_text = @editor_state.buffer[block[:start_index]...@editor_state.cursor].to_s
1007
+ return if inserted_text.empty?
1008
+
1009
+ block[:end_line].downto(block[:start_line] + 1) do |line_index|
1010
+ line_start = @editor_state.line_start_offset(line_index)
1011
+ line_length = @editor_state.lines[line_index].to_s.length
1012
+ @editor_state.cursor = line_start + [block[:column], line_length].min
1013
+ @editor_state.insert(inserted_text)
1014
+ end
1015
+ end
1016
+
1017
+ def vibe_transform_visual_selection(transform)
1018
+ range = vibe_visual_range
1019
+ return false unless range
1020
+
1021
+ text = @editor_state.buffer[range[0]...range[1]].to_s
1022
+ replacement = case transform
1023
+ when :swapcase then text.swapcase
1024
+ when :downcase then text.downcase
1025
+ else text.upcase
1026
+ end
1027
+ vibe_record_undo { @editor_state.replace_range(range[0], range[1], replacement) }
1028
+ vibe_cancel_visual_mode
1029
+ end
1030
+
1031
+ def vibe_join_visual_selection
1032
+ range = vibe_visual_range
1033
+ return false unless range
1034
+
1035
+ start_line, = @editor_state.cursor_line_and_column_for(range[0])
1036
+ end_line, = @editor_state.cursor_line_and_column_for([range[1] - 1, range[0]].max)
1037
+ @editor_state.set_cursor_line_and_column(start_line, 0)
1038
+ vibe_join_lines(end_line - start_line + 1)
1039
+ vibe_cancel_visual_mode
1040
+ end
1041
+
1042
+ def vibe_indent_visual_selection(direction)
1043
+ range = vibe_visual_range
1044
+ return false unless range
1045
+
1046
+ start_line, = @editor_state.cursor_line_and_column_for(range[0])
1047
+ end_line, = @editor_state.cursor_line_and_column_for([range[1] - 1, range[0]].max)
1048
+ start_index = @editor_state.line_range(start_line)[0]
1049
+ end_index = @editor_state.line_range(end_line)[1]
1050
+ original_text = @editor_state.buffer[start_index...end_index].to_s
1051
+ lines = @editor_state.lines[start_line..end_line].map do |line|
1052
+ direction == :right ? " #{line}" : line.sub(/\A(?: |\t| )/, "")
1053
+ end
1054
+ replacement = lines.join("\n")
1055
+ replacement += "\n" if original_text.end_with?("\n")
1056
+
1057
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, replacement) }
1058
+ vibe_cancel_visual_mode
1059
+ end
1060
+
1061
+ def vibe_count_and_body(command)
1062
+ return [0, "0"] if command == "0"
1063
+
1064
+ match = command.match(/\A(\d*)(.*)\z/)
1065
+ [match[1].to_i, match[2]]
1066
+ end
1067
+
1068
+ def vibe_move_to_relative_line_first_non_blank(offset)
1069
+ line, = @editor_state.cursor_line_and_column
1070
+ @editor_state.move_to_line_first_non_blank(line + offset)
1071
+ end
1072
+
1073
+ def vibe_move_to_screen_line(offset)
1074
+ target_row = @editor_state.viewport_row + offset
1075
+ if current_editor_soft_wrap?
1076
+ visual_rows = editor_visual_rows(current_editor_text_width)
1077
+ line_index = visual_rows[target_row]&.fetch(:line_index) || @editor_state.lines.length - 1
1078
+ @editor_state.move_to_line_first_non_blank(line_index)
1079
+ else
1080
+ @editor_state.move_to_line_first_non_blank(target_row)
1081
+ end
1082
+ end
1083
+
1084
+ def vibe_position_cursor_line(position)
1085
+ row = if current_editor_soft_wrap?
1086
+ editor_visual_row_for(*@editor_state.cursor_line_and_column, current_editor_text_width)
1087
+ else
1088
+ @editor_state.cursor_line_and_column.first
1089
+ end
1090
+ offset = case position
1091
+ when :top then 0
1092
+ when :bottom then editor_page_rows - 1
1093
+ else editor_page_rows / 2
1094
+ end
1095
+ @editor_state.viewport_row = [[row - offset, 0].max, vibe_last_viewport_row].min
1096
+ end
1097
+
1098
+ def vibe_last_viewport_row
1099
+ visible_count = editor_page_rows
1100
+ if current_editor_soft_wrap?
1101
+ [editor_visual_rows(current_editor_text_width).length - visible_count, 0].max
1102
+ else
1103
+ [@editor_state.lines.length - visible_count, 0].max
1104
+ end
1105
+ end
1106
+
1107
+ def vibe_half_page_rows
1108
+ [editor_page_rows / 2, 1].max
1109
+ end
1110
+
1111
+ def vibe_scroll_down
1112
+ @editor_state.viewport_row = [@editor_state.viewport_row + 1, @editor_state.lines.length - 1].min
1113
+ line, column = @editor_state.cursor_line_and_column
1114
+ @editor_state.set_cursor_line_and_column(@editor_state.viewport_row, column) if line < @editor_state.viewport_row
1115
+ end
1116
+
1117
+ def vibe_scroll_up
1118
+ @editor_state.viewport_row = [@editor_state.viewport_row - 1, 0].max
1119
+ bottom_line = @editor_state.viewport_row + editor_page_rows - 1
1120
+ line, column = @editor_state.cursor_line_and_column
1121
+ @editor_state.set_cursor_line_and_column(bottom_line, column) if line > bottom_line
1122
+ end
1123
+
1124
+ def vibe_open_line_below
1125
+ line, = @editor_state.cursor_line_and_column
1126
+ indentation = @editor_state.lines[line].to_s[/\A\s*/].to_s
1127
+ line_end = @editor_state.line_start_offset(line) + @editor_state.lines[line].to_s.length
1128
+ vibe_record_undo do
1129
+ @editor_state.cursor = line_end
1130
+ @editor_state.insert("\n#{indentation}")
1131
+ end
1132
+ @editor_state.vibe_mode = "insert"
1133
+ @editor_state.status = "INSERT · Esc normal"
1134
+ end
1135
+
1136
+ def vibe_open_line_above
1137
+ line, = @editor_state.cursor_line_and_column
1138
+ indentation = @editor_state.lines[line].to_s[/\A\s*/].to_s
1139
+ start_index = @editor_state.line_start_offset(line)
1140
+ vibe_record_undo do
1141
+ @editor_state.cursor = start_index
1142
+ @editor_state.insert("#{indentation}\n")
1143
+ @editor_state.cursor = start_index + indentation.length
1144
+ end
1145
+ @editor_state.vibe_mode = "insert"
1146
+ @editor_state.status = "INSERT · Esc normal"
1147
+ end
1148
+
1149
+ def vibe_paste_before(command = nil)
1150
+ text = vibe_active_register_text
1151
+ return false if text.empty?
1152
+
1153
+ vibe_record_undo do
1154
+ @editor_state.cursor = @editor_state.current_line_range.first if text.end_with?("\n")
1155
+ @editor_state.insert(text)
1156
+ end
1157
+ vibe_remember_change(command)
1158
+ end
1159
+
1160
+ def vibe_delete_lines(count)
1161
+ start_index, end_index = vibe_linewise_delete_range(count)
1162
+ @editor_state.copy_range(start_index, end_index)
1163
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1164
+ @editor_state.status = "Deleted #{count} line#{count == 1 ? "" : "s"}"
1165
+ end
1166
+
1167
+ def vibe_linewise_delete_range(count)
1168
+ line, = @editor_state.cursor_line_and_column
1169
+ start_index, = @editor_state.line_range(line)
1170
+ end_line = [line + count - 1, @editor_state.lines.length - 1].min
1171
+ _, end_index = @editor_state.line_range(end_line)
1172
+ if end_index == @editor_state.buffer.length && start_index.positive?
1173
+ start_index -= 1
1174
+ end
1175
+ [start_index, end_index]
1176
+ end
1177
+
1178
+ def vibe_yank_lines(count)
1179
+ line, = @editor_state.cursor_line_and_column
1180
+ start_index, = @editor_state.line_range(line)
1181
+ end_line = [line + count - 1, @editor_state.lines.length - 1].min
1182
+ _, end_index = @editor_state.line_range(end_line)
1183
+ vibe_copy_range(start_index, end_index, "Yanked #{count} line#{count == 1 ? "" : "s"}")
1184
+ end
1185
+
1186
+ def vibe_change_lines(count, command = nil)
1187
+ start_index, end_index = vibe_linewise_change_range(count)
1188
+ @editor_state.copy_range(start_index, end_index)
1189
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1190
+ @editor_state.cursor = start_index
1191
+ vibe_enter_insert_mode(command)
1192
+ end
1193
+
1194
+ def vibe_linewise_change_range(count)
1195
+ line, = @editor_state.cursor_line_and_column
1196
+ start_index = @editor_state.line_start_offset(line)
1197
+ end_line = [line + count - 1, @editor_state.lines.length - 1].min
1198
+ end_index = @editor_state.line_start_offset(end_line) + @editor_state.lines[end_line].to_s.length
1199
+ [start_index, end_index]
1200
+ end
1201
+
1202
+ def vibe_change_to_line_end(command = nil)
1203
+ start_index = @editor_state.cursor
1204
+ line, = @editor_state.cursor_line_and_column
1205
+ end_index = @editor_state.line_start_offset(line) + @editor_state.lines[line].to_s.length
1206
+ return vibe_enter_insert_mode(command) if start_index == end_index
1207
+
1208
+ @editor_state.copy_range(start_index, end_index)
1209
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1210
+ vibe_enter_insert_mode(command)
1211
+ end
1212
+
1213
+ def vibe_delete_to_line_end(command = nil)
1214
+ start_index = @editor_state.cursor
1215
+ line, = @editor_state.cursor_line_and_column
1216
+ end_index = @editor_state.line_start_offset(line) + @editor_state.lines[line].to_s.length
1217
+ return @editor_state.status = "Empty range" if start_index == end_index
1218
+
1219
+ @editor_state.copy_range(start_index, end_index)
1220
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1221
+ @editor_state.status = "Deleted"
1222
+ vibe_remember_change(command)
1223
+ end
1224
+
1225
+ def vibe_substitute_characters(count, command = nil)
1226
+ start_index = @editor_state.cursor
1227
+ end_index = [start_index + count, @editor_state.buffer.length].min
1228
+ @editor_state.copy_range(start_index, end_index)
1229
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1230
+ vibe_enter_insert_mode(command)
1231
+ end
1232
+
1233
+ def vibe_replace_single_character(character, count, command = nil)
1234
+ return @editor_state.status = "Replacement character required" if character.to_s.empty?
1235
+
1236
+ vibe_record_undo do
1237
+ count.times do
1238
+ @editor_state.delete_at_cursor
1239
+ @editor_state.insert(character)
1240
+ end
1241
+ end
1242
+ @editor_state.move_left
1243
+ vibe_remember_change(command)
1244
+ end
1245
+
1246
+ def vibe_join_lines(count, command = nil)
1247
+ line, = @editor_state.cursor_line_and_column
1248
+ join_count = [count, 2].max
1249
+ end_line = [line + join_count - 1, @editor_state.lines.length - 1].min
1250
+ return @editor_state.status = "Already at last line" if end_line == line
1251
+
1252
+ vibe_record_undo do
1253
+ (end_line - line).times do
1254
+ line_end = @editor_state.line_start_offset(line) + @editor_state.lines[line].to_s.length
1255
+ next_line_start = line_end + 1
1256
+ next_line_end = next_line_start + @editor_state.lines[line + 1].to_s.length
1257
+ next_line = @editor_state.buffer[next_line_start...next_line_end].to_s.sub(/\A\s+/, "")
1258
+ separator = next_line.empty? ? "" : " "
1259
+ @editor_state.replace_range(line_end, next_line_end, separator + next_line)
1260
+ @editor_state.cursor = line_end
1261
+ end
1262
+ end
1263
+ vibe_remember_change(command)
1264
+ end
1265
+
1266
+ def vibe_enter_insert_mode(command = nil)
1267
+ @editor_state.vibe_mode = "insert"
1268
+ @editor_state.status = "INSERT · Esc normal"
1269
+ vibe_begin_change_recording(command) if command
1270
+ end
1271
+
1272
+ def vibe_operator_motion(operator, motion, count, command = nil)
1273
+ motion_count, motion = vibe_count_and_body(motion)
1274
+ count *= motion_count if motion_count.positive?
1275
+ return vibe_operator_linewise(operator, count, command) if motion == operator
1276
+
1277
+ target = vibe_operator_target(motion, count)
1278
+ return false unless target
1279
+ return @editor_state.status = "Empty range" if target.start_index == target.end_index
1280
+
1281
+ vibe_apply_operator_to_target(operator, target, command, motion, count, motion_count)
1282
+ end
1283
+
1284
+ def vibe_active_register_text
1285
+ return @editor_state.vibe_registers[@vibe_active_register].to_s if @vibe_active_register
1286
+
1287
+ @editor_state.kill_buffer.to_s
1288
+ end
1289
+
1290
+ def vibe_store_active_register
1291
+ return unless @vibe_active_register
1292
+
1293
+ @editor_state.vibe_registers[@vibe_active_register] = @editor_state.kill_buffer.to_s
1294
+ end
1295
+
1296
+ def vibe_apply_operator_to_target(operator, target, command, motion, count, motion_count)
1297
+ case operator
1298
+ when "d"
1299
+ @editor_state.copy_range(target.start_index, target.end_index)
1300
+ vibe_record_undo { @editor_state.replace_range(target.start_index, target.end_index, "") }
1301
+ @editor_state.status = "Deleted"
1302
+ vibe_store_active_register
1303
+ vibe_remember_change(command)
1304
+ when "c"
1305
+ @editor_state.copy_range(target.start_index, target.end_index)
1306
+ vibe_record_undo do
1307
+ @editor_state.replace_range(target.start_index, target.end_index, target.change_replacement_text)
1308
+ @editor_state.cursor = target.change_cursor_index
1309
+ end
1310
+ vibe_store_active_register
1311
+ vibe_enter_insert_mode(vibe_build_change_command(operator, motion, count, motion_count))
1312
+ else
1313
+ vibe_copy_range(target.start_index, target.end_index, "Yanked")
1314
+ vibe_store_active_register
1315
+ @editor_state.cursor = target.start_index
1316
+ end
1317
+ end
1318
+
1319
+ def vibe_operator_target(motion, count)
1320
+ return vibe_text_object_target(motion) if motion.match?(/\A[ai].\z/)
1321
+ return vibe_word_motion_target(motion, count) if %w[w e b].include?(motion)
1322
+ return vibe_find_motion_target(motion, count) if motion.match?(/\A[fFtT].\z/)
1323
+ return vibe_percent_motion_target if motion == "%"
1324
+
1325
+ start_index = @editor_state.cursor
1326
+ return false unless vibe_apply_motion(motion, count)
1327
+
1328
+ end_index = @editor_state.cursor
1329
+ VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: end_index)
1330
+ end
1331
+
1332
+ def vibe_find_motion_target(motion, count)
1333
+ start_index = @editor_state.cursor
1334
+ command = motion[0]
1335
+ char = motion[1]
1336
+ reverse = %w[F T].include?(command)
1337
+ before = %w[t T].include?(command)
1338
+ end_index = vibe_find_character_index(char, count, reverse: reverse)
1339
+ unless end_index
1340
+ @editor_state.status = "Character not found: #{char}"
1341
+ return false
1342
+ end
1343
+
1344
+ motion_index = end_index
1345
+ motion_index += reverse ? 1 : -1 if before
1346
+ @editor_state.cursor = motion_index
1347
+ target_end_index = if reverse
1348
+ before ? end_index + 1 : end_index
1349
+ else
1350
+ before ? end_index : end_index + 1
1351
+ end
1352
+ VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: target_end_index)
1353
+ end
1354
+
1355
+ def vibe_percent_motion_target
1356
+ start_index = @editor_state.cursor
1357
+ end_index = vibe_matching_pair_index(start_index)
1358
+ unless end_index
1359
+ @editor_state.status = "No matching pair under cursor"
1360
+ return false
1361
+ end
1362
+
1363
+ if end_index > start_index
1364
+ VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: end_index + 1)
1365
+ else
1366
+ VibeOperatorTarget.new(type: :characterwise, start_index: end_index, end_index: start_index + 1)
1367
+ end
1368
+ end
1369
+
1370
+ def vibe_word_motion_target(motion, count)
1371
+ start_index = @editor_state.cursor
1372
+ end_index = start_index
1373
+ if motion == "w"
1374
+ end_index = vibe_word_operator_forward_index(end_index, count)
1375
+ else
1376
+ count.times { end_index = vibe_word_motion_index(motion, end_index) }
1377
+ end_index = [end_index + 1, @editor_state.buffer.length].min if motion == "e"
1378
+ end
1379
+ @editor_state.cursor = end_index
1380
+ VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: end_index)
1381
+ end
1382
+
1383
+ def vibe_word_operator_forward_index(index, count)
1384
+ cursor = index
1385
+ buffer = @editor_state.buffer
1386
+ count.times do |step|
1387
+ current_kind = vibe_word_kind(buffer[cursor])
1388
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == current_kind
1389
+ if step < count - 1
1390
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
1391
+ end
1392
+ end
1393
+ cursor
1394
+ end
1395
+
1396
+ def vibe_word_motion_index(motion, index)
1397
+ original_cursor = @editor_state.cursor
1398
+ @editor_state.cursor = index
1399
+ case motion
1400
+ when "w"
1401
+ vibe_move_to_next_word_start
1402
+ when "e"
1403
+ vibe_move_to_word_end
1404
+ else
1405
+ vibe_move_to_previous_word_start
1406
+ end
1407
+ @editor_state.cursor
1408
+ ensure
1409
+ @editor_state.cursor = original_cursor
1410
+ end
1411
+
1412
+ def vibe_text_object_target(text_object)
1413
+ case text_object
1414
+ when "iw"
1415
+ vibe_inner_word_target
1416
+ when "aw"
1417
+ vibe_a_word_target
1418
+ when "ir", "ar"
1419
+ vibe_ruby_block_target(text_object)
1420
+ when "ip", "ap"
1421
+ vibe_paragraph_target(text_object)
1422
+ else
1423
+ return vibe_pair_text_object_target(text_object) if VIBE_PAIR_TEXT_OBJECTS.key?(text_object[1])
1424
+
1425
+ @editor_state.status = "Unsupported text object: #{text_object}"
1426
+ false
1427
+ end
1428
+ end
1429
+
1430
+ def vibe_paragraph_target(text_object)
1431
+ line, = @editor_state.cursor_line_and_column
1432
+ lines = @editor_state.lines
1433
+ if lines[line].to_s.strip.empty?
1434
+ @editor_state.status = "Paragraph not found"
1435
+ return false
1436
+ end
1437
+
1438
+ start_line = line
1439
+ start_line -= 1 while start_line.positive? && !lines[start_line - 1].to_s.strip.empty?
1440
+ end_line = line
1441
+ end_line += 1 while end_line < lines.length - 1 && !lines[end_line + 1].to_s.strip.empty?
1442
+
1443
+ if text_object == "ap"
1444
+ if end_line < lines.length - 1 && lines[end_line + 1].to_s.strip.empty?
1445
+ end_line += 1 while end_line < lines.length - 1 && lines[end_line + 1].to_s.strip.empty?
1446
+ else
1447
+ start_line -= 1 while start_line.positive? && lines[start_line - 1].to_s.strip.empty?
1448
+ end
1449
+ end
1450
+
1451
+ VibeOperatorTarget.new(
1452
+ type: :characterwise,
1453
+ start_index: @editor_state.line_range(start_line)[0],
1454
+ end_index: @editor_state.line_range(end_line)[1]
1455
+ )
1456
+ end
1457
+
1458
+ def vibe_ruby_block_target(text_object)
1459
+ unless vibe_ruby_file?
1460
+ @editor_state.status = "Ruby text object requires Ruby file"
1461
+ return false
1462
+ end
1463
+
1464
+ block = vibe_enclosing_ruby_block
1465
+ unless block
1466
+ @editor_state.status = "Ruby block not found"
1467
+ return false
1468
+ end
1469
+
1470
+ start_line = block[:start_line]
1471
+ end_line = block[:end_line]
1472
+ if text_object.start_with?("i")
1473
+ start_line += 1
1474
+ end_line -= 1
1475
+ end
1476
+ if start_line > end_line
1477
+ @editor_state.status = "Empty Ruby block"
1478
+ return false
1479
+ end
1480
+
1481
+ replacement_text = nil
1482
+ replacement_cursor_offset = nil
1483
+ if text_object == "ir"
1484
+ indentation = @editor_state.lines[start_line].to_s[/\A\s*/].to_s
1485
+ replacement_text = "#{indentation}\n"
1486
+ replacement_cursor_offset = indentation.length
1487
+ end
1488
+
1489
+ VibeOperatorTarget.new(
1490
+ type: :characterwise,
1491
+ start_index: @editor_state.line_range(start_line)[0],
1492
+ end_index: @editor_state.line_range(end_line)[1],
1493
+ replacement_text: replacement_text,
1494
+ replacement_cursor_offset: replacement_cursor_offset
1495
+ )
1496
+ end
1497
+
1498
+ def vibe_ruby_file?
1499
+ path = File.basename(@editor_state.path.to_s)
1500
+ VIBE_RUBY_PATHS.include?(path) || VIBE_RUBY_EXTENSIONS.include?(File.extname(path))
1501
+ end
1502
+
1503
+ def vibe_enclosing_ruby_block
1504
+ blocks = []
1505
+ stack = []
1506
+ @editor_state.lines.each_with_index do |line, line_index|
1507
+ tokens = vibe_ruby_block_tokens(line)
1508
+ tokens.each do |token|
1509
+ if token == "end"
1510
+ opener = stack.pop
1511
+ blocks << opener.merge(end_line: line_index) if opener
1512
+ elsif VIBE_RUBY_BLOCK_OPENERS.include?(token)
1513
+ stack << { opener: token, start_line: line_index }
1514
+ end
1515
+ end
1516
+ end
1517
+
1518
+ cursor_line, = @editor_state.cursor_line_and_column
1519
+ blocks.select { |block| block[:start_line] <= cursor_line && cursor_line <= block[:end_line] }
1520
+ .max_by { |block| block[:start_line] }
1521
+ end
1522
+
1523
+ def vibe_ruby_block_tokens(line)
1524
+ code = vibe_ruby_code_for_block_scan(line)
1525
+ tokens = []
1526
+ stripped = code.strip
1527
+ opener = stripped.match(/\A(if|unless|case|while|until|for|def|module|class|begin)\b/)
1528
+ tokens << opener[1] if opener
1529
+ tokens << "do" if stripped.match?(/\bdo\b/)
1530
+ tokens << "end" if stripped.match?(/\Aend\b/)
1531
+ tokens
1532
+ end
1533
+
1534
+ def vibe_ruby_code_for_block_scan(line)
1535
+ code = +""
1536
+ quote = nil
1537
+ escaped = false
1538
+ line.each_char do |char|
1539
+ if quote
1540
+ escaped = !escaped && char == "\\"
1541
+ quote = nil if char == quote && !escaped
1542
+ next
1543
+ end
1544
+
1545
+ break if char == "#"
1546
+ if ["'", '"'].include?(char)
1547
+ quote = char
1548
+ escaped = false
1549
+ next
1550
+ end
1551
+ code << char
1552
+ end
1553
+ code
1554
+ end
1555
+
1556
+ def vibe_pair_text_object_target(text_object)
1557
+ include_pair = text_object.start_with?("a")
1558
+ pair = VIBE_PAIR_TEXT_OBJECTS[text_object[1]]
1559
+ range = pair[0] == pair[1] ? vibe_quote_pair_range(pair[0]) : vibe_delimited_pair_range(pair[0], pair[1])
1560
+ return @editor_state.status = "No #{pair.join} pair around cursor" unless range
1561
+
1562
+ start_index, end_index = range
1563
+ start_index += 1 unless include_pair
1564
+ VibeOperatorTarget.new(
1565
+ type: :characterwise,
1566
+ start_index: start_index,
1567
+ end_index: include_pair ? end_index + 1 : end_index
1568
+ )
1569
+ end
1570
+
1571
+ def vibe_find_character(command, char, count)
1572
+ reverse = %w[F T].include?(command)
1573
+ before = %w[t T].include?(command)
1574
+ index = vibe_find_character_index(char, count, reverse: reverse)
1575
+ unless index
1576
+ @editor_state.status = "Character not found: #{char}"
1577
+ return false
1578
+ end
1579
+
1580
+ index += reverse ? 1 : -1 if before
1581
+ @editor_state.cursor = [[index, 0].max, @editor_state.buffer.length].min
1582
+ @editor_state.vibe_last_find = { command: command, char: char }
1583
+ true
1584
+ end
1585
+
1586
+ def vibe_repeat_find_character(reverse: false)
1587
+ last_find = @editor_state.vibe_last_find
1588
+ return @editor_state.status = "No character find to repeat" unless last_find
1589
+
1590
+ command = last_find[:command]
1591
+ command = vibe_reverse_find_command(command) if reverse
1592
+ vibe_find_character(command, last_find[:char], 1)
1593
+ end
1594
+
1595
+ def vibe_reverse_find_command(command)
1596
+ { "f" => "F", "F" => "f", "t" => "T", "T" => "t" }.fetch(command)
1597
+ end
1598
+
1599
+ def vibe_find_character_index(char, count, reverse: false)
1600
+ line, = @editor_state.cursor_line_and_column
1601
+ line_range = @editor_state.line_range(line)
1602
+ line_start = line_range[0]
1603
+ line_end = line_range[1]
1604
+ line_end -= 1 if line_end > line_start && @editor_state.buffer[line_end - 1] == "\n"
1605
+ cursor = @editor_state.cursor
1606
+ count.times do
1607
+ cursor = if reverse
1608
+ @editor_state.buffer.rindex(char, cursor - 1)
1609
+ else
1610
+ @editor_state.buffer.index(char, cursor + 1)
1611
+ end
1612
+ return nil unless cursor && cursor >= line_start && cursor < line_end
1613
+ end
1614
+ cursor
1615
+ end
1616
+
1617
+ def vibe_jump_to_matching_pair
1618
+ index = vibe_matching_pair_index(@editor_state.cursor)
1619
+ unless index
1620
+ @editor_state.status = "No matching pair under cursor"
1621
+ return false
1622
+ end
1623
+
1624
+ @editor_state.cursor = index
1625
+ true
1626
+ end
1627
+
1628
+ def vibe_matching_pair_index(index)
1629
+ pairs = VIBE_PAIR_TEXT_OBJECTS.values.uniq.reject { |open_char, close_char| open_char == close_char }
1630
+ pairs.each do |open_char, close_char|
1631
+ if @editor_state.buffer[index] == open_char
1632
+ return vibe_find_forward_pair(index, open_char, close_char)
1633
+ elsif @editor_state.buffer[index] == close_char
1634
+ return vibe_find_backward_pair(index, open_char, close_char)
1635
+ end
1636
+ end
1637
+ nil
1638
+ end
1639
+
1640
+ def vibe_find_forward_pair(open_index, open_char, close_char)
1641
+ depth = 0
1642
+ (open_index + 1...@editor_state.buffer.length).each do |index|
1643
+ char = @editor_state.buffer[index]
1644
+ depth += 1 if char == open_char
1645
+ if char == close_char
1646
+ return index if depth.zero?
1647
+
1648
+ depth -= 1
1649
+ end
1650
+ end
1651
+ open_index
1652
+ end
1653
+
1654
+ def vibe_find_backward_pair(close_index, open_char, close_char)
1655
+ depth = 0
1656
+ (close_index - 1).downto(0) do |index|
1657
+ char = @editor_state.buffer[index]
1658
+ depth += 1 if char == close_char
1659
+ if char == open_char
1660
+ return index if depth.zero?
1661
+
1662
+ depth -= 1
1663
+ end
1664
+ end
1665
+ close_index
1666
+ end
1667
+
1668
+ def vibe_delimited_pair_range(open_char, close_char)
1669
+ buffer = @editor_state.buffer
1670
+ cursor = @editor_state.cursor
1671
+ depth = 0
1672
+ open_index = nil
1673
+ cursor.downto(0) do |index|
1674
+ char = buffer[index]
1675
+ depth += 1 if char == close_char
1676
+ if char == open_char
1677
+ if depth.zero?
1678
+ open_index = index
1679
+ break
1680
+ end
1681
+ depth -= 1
1682
+ end
1683
+ end
1684
+ return nil unless open_index
1685
+
1686
+ depth = 0
1687
+ close_index = nil
1688
+ (open_index + 1...buffer.length).each do |index|
1689
+ char = buffer[index]
1690
+ depth += 1 if char == open_char
1691
+ if char == close_char
1692
+ if depth.zero?
1693
+ close_index = index
1694
+ break
1695
+ end
1696
+ depth -= 1
1697
+ end
1698
+ end
1699
+ close_index ? [open_index, close_index] : nil
1700
+ end
1701
+
1702
+ def vibe_quote_pair_range(quote)
1703
+ quote_indexes = vibe_unescaped_quote_indexes(quote)
1704
+ cursor = @editor_state.cursor
1705
+ open_index = quote_indexes.select { |index| index <= cursor }.last
1706
+ return nil unless open_index
1707
+
1708
+ close_index = quote_indexes.find { |index| index > open_index }
1709
+ close_index ? [open_index, close_index] : nil
1710
+ end
1711
+
1712
+ def vibe_unescaped_quote_indexes(quote)
1713
+ indexes = []
1714
+ @editor_state.buffer.each_char.with_index do |char, index|
1715
+ indexes << index if char == quote && !vibe_escaped_character?(index)
1716
+ end
1717
+ indexes
1718
+ end
1719
+
1720
+ def vibe_escaped_character?(index)
1721
+ backslashes = 0
1722
+ cursor = index - 1
1723
+ while cursor >= 0 && @editor_state.buffer[cursor] == "\\"
1724
+ backslashes += 1
1725
+ cursor -= 1
1726
+ end
1727
+ backslashes.odd?
1728
+ end
1729
+
1730
+ def vibe_inner_word_target
1731
+ range = vibe_word_range_at(@editor_state.cursor)
1732
+ return @editor_state.status = "No word under cursor" unless range
1733
+
1734
+ VibeOperatorTarget.new(type: :characterwise, start_index: range[0], end_index: range[1])
1735
+ end
1736
+
1737
+ def vibe_a_word_target
1738
+ range = vibe_word_range_at(@editor_state.cursor)
1739
+ return @editor_state.status = "No word under cursor" unless range
1740
+
1741
+ start_index, end_index = range
1742
+ if end_index < @editor_state.buffer.length && vibe_word_kind(@editor_state.buffer[end_index]) == :space
1743
+ end_index += 1 while end_index < @editor_state.buffer.length && vibe_word_kind(@editor_state.buffer[end_index]) == :space
1744
+ else
1745
+ start_index -= 1 while start_index.positive? && vibe_word_kind(@editor_state.buffer[start_index - 1]) == :space
1746
+ end
1747
+ VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: end_index)
1748
+ end
1749
+
1750
+ def vibe_word_range_at(offset)
1751
+ buffer = @editor_state.buffer
1752
+ return nil if buffer.empty?
1753
+
1754
+ index = [[offset.to_i, 0].max, buffer.length - 1].min
1755
+ kind = vibe_word_kind(buffer[index])
1756
+ return nil if kind == :space
1757
+
1758
+ start_index = index
1759
+ start_index -= 1 while start_index.positive? && vibe_word_kind(buffer[start_index - 1]) == kind
1760
+ end_index = index + 1
1761
+ end_index += 1 while end_index < buffer.length && vibe_word_kind(buffer[end_index]) == kind
1762
+ [start_index, end_index]
1763
+ end
1764
+
1765
+ def vibe_operator_linewise(operator, count, command = nil)
1766
+ case operator
1767
+ when "d"
1768
+ vibe_delete_lines(count)
1769
+ vibe_remember_change(command)
1770
+ when "c"
1771
+ vibe_change_lines(count, command)
1772
+ else
1773
+ vibe_yank_lines(count)
1774
+ end
1775
+ end
1776
+
1777
+ def vibe_apply_cursor_motion(motion, count)
1778
+ case motion
1779
+ when "w"
1780
+ count.times { vibe_move_to_next_word_start }
1781
+ when "e"
1782
+ count.times { vibe_move_to_word_end }
1783
+ when "b"
1784
+ count.times { vibe_move_to_previous_word_start }
1785
+ else
1786
+ return vibe_apply_motion(motion, count)
1787
+ end
1788
+ true
1789
+ end
1790
+
1791
+ def vibe_apply_motion(motion, count)
1792
+ case motion
1793
+ when "w"
1794
+ count.times { @editor_state.move_to_next_word }
1795
+ when "e"
1796
+ count.times { @editor_state.move_to_word_end }
1797
+ when "b"
1798
+ count.times { @editor_state.move_to_previous_word }
1799
+ when "$"
1800
+ @editor_state.move_line_end
1801
+ when "0"
1802
+ @editor_state.move_line_start
1803
+ when "^"
1804
+ @editor_state.move_line_first_non_blank
1805
+ when "+", "\n", "\r"
1806
+ vibe_move_to_relative_line_first_non_blank(count)
1807
+ when "-"
1808
+ vibe_move_to_relative_line_first_non_blank(-count)
1809
+ when "_"
1810
+ vibe_move_to_relative_line_first_non_blank(count - 1)
1811
+ when "h", "\b", "\x7F"
1812
+ count.times { @editor_state.move_left }
1813
+ when "j"
1814
+ count.times { editor_move_down }
1815
+ when "k"
1816
+ count.times { editor_move_up }
1817
+ when "l", " "
1818
+ count.times { @editor_state.move_right }
1819
+ when "}"
1820
+ count.times { vibe_move_paragraph_forward }
1821
+ when "{"
1822
+ count.times { vibe_move_paragraph_backward }
1823
+ else
1824
+ @editor_state.status = "Unsupported motion: #{motion}"
1825
+ return false
1826
+ end
1827
+ true
1828
+ end
1829
+
1830
+ def vibe_move_paragraph_forward
1831
+ line, = @editor_state.cursor_line_and_column
1832
+ lines = @editor_state.lines
1833
+ line += 1 while line < lines.length - 1 && !lines[line].to_s.strip.empty?
1834
+ line += 1 while line < lines.length - 1 && lines[line].to_s.strip.empty?
1835
+ @editor_state.set_cursor_line_and_column(line, 0)
1836
+ end
1837
+
1838
+ def vibe_move_paragraph_backward
1839
+ line, = @editor_state.cursor_line_and_column
1840
+ lines = @editor_state.lines
1841
+ line -= 1 if line.positive?
1842
+ line -= 1 while line.positive? && lines[line].to_s.strip.empty?
1843
+ line -= 1 while line.positive? && !lines[line - 1].to_s.strip.empty?
1844
+ @editor_state.set_cursor_line_and_column(line, 0)
1845
+ end
1846
+
1847
+ def vibe_move_to_next_word_start
1848
+ cursor = @editor_state.cursor
1849
+ buffer = @editor_state.buffer
1850
+ return if cursor >= buffer.length
1851
+
1852
+ current_kind = vibe_word_kind(buffer[cursor])
1853
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == current_kind
1854
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
1855
+ @editor_state.cursor = cursor
1856
+ end
1857
+
1858
+ def vibe_move_to_word_end
1859
+ cursor = @editor_state.cursor
1860
+ buffer = @editor_state.buffer
1861
+ return if buffer.empty? || cursor >= buffer.length
1862
+
1863
+ current_kind = vibe_word_kind(buffer[cursor])
1864
+ next_kind = cursor < buffer.length - 1 ? vibe_word_kind(buffer[cursor + 1]) : nil
1865
+ cursor += 1 if current_kind != :space && next_kind && next_kind != current_kind
1866
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
1867
+ return @editor_state.cursor = cursor if cursor >= buffer.length
1868
+
1869
+ current_kind = vibe_word_kind(buffer[cursor])
1870
+ cursor += 1 while cursor < buffer.length - 1 && vibe_word_kind(buffer[cursor + 1]) == current_kind
1871
+ @editor_state.cursor = cursor
1872
+ end
1873
+
1874
+ def vibe_move_to_previous_word_start
1875
+ cursor = @editor_state.cursor
1876
+ buffer = @editor_state.buffer
1877
+ return if cursor.zero? || buffer.empty?
1878
+
1879
+ cursor -= 1
1880
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
1881
+ current_kind = vibe_word_kind(buffer[cursor])
1882
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor - 1]) == current_kind
1883
+ @editor_state.cursor = cursor
1884
+ end
1885
+
1886
+ def vibe_word_kind(char)
1887
+ case char.to_s
1888
+ when /\s/
1889
+ :space
1890
+ when /[[:alnum:]_]/
1891
+ :keyword
1892
+ else
1893
+ :punctuation
1894
+ end
1895
+ end
1896
+
1897
+ def vibe_copy_range(start_index, end_index, status)
1898
+ @editor_state.copy_range(start_index, end_index)
1899
+ @output_io.print("\e]52;c;#{Base64.strict_encode64(@editor_state.kill_buffer)}\a")
1900
+ @output_io.flush if @output_io.respond_to?(:flush)
1901
+ @editor_state.status = status
1902
+ end
1903
+
1904
+ def vibe_opposite_search_direction
1905
+ @editor_state.search_direction == :backward ? :forward : :backward
1906
+ end
1907
+
1908
+ def vibe_restore_current_line
1909
+ line, = @editor_state.cursor_line_and_column
1910
+ start_index = @editor_state.line_start_offset(line)
1911
+ end_index = start_index + @editor_state.lines[line].to_s.length
1912
+ original_line = @editor_state.original_content.split("\n", -1)[line].to_s
1913
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, original_line) }
1914
+ @editor_state.status = "Restored line"
1915
+ end
1916
+
1917
+ def handle_vibe_repeat_change
1918
+ change = @editor_state.vibe_last_change
1919
+ return @editor_state.status = "No change to repeat" unless change
1920
+
1921
+ change.dup.each { |key| handle_vibe_key(key) }
1922
+ true
1923
+ end
1924
+
1925
+ def vibe_begin_change_recording(command)
1926
+ @editor_state.vibe_last_change = vibe_change_keys(command)
1927
+ end
1928
+
1929
+ def vibe_record_insert_change_key(key)
1930
+ return unless @editor_state.vibe_last_change
1931
+ return if ["\x03"].include?(key)
1932
+
1933
+ @editor_state.vibe_last_change << key
1934
+ end
1935
+
1936
+ def vibe_remember_change(command)
1937
+ @editor_state.vibe_last_change = vibe_change_keys(command) if command
1938
+ end
1939
+
1940
+ def vibe_build_change_command(operator, motion, count, motion_count)
1941
+ command = +""
1942
+ command << count.to_s if count > 1 && motion_count.zero?
1943
+ command << operator
1944
+ command << motion_count.to_s if motion_count.positive?
1945
+ command << motion
1946
+ vibe_change_keys(command)
1947
+ end
1948
+
1949
+ def vibe_change_keys(command)
1950
+ Array(command).flat_map { |key| key.is_a?(String) ? key.each_char.to_a : key }
1951
+ end
1952
+
1953
+ def vibe_record_undo
1954
+ before = @editor_state.buffer.dup
1955
+ @editor_state.push_undo
1956
+ yield
1957
+ @editor_state.undo_stack.pop if @editor_state.buffer == before
1958
+ end
1959
+
1960
+ end
1961
+ end
1962
+ end