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