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,1271 @@
1
+ require_relative "../../editor_mode"
2
+ require_relative "../../text_boundary"
3
+ require_relative "buffer"
4
+ require_relative "file_marker"
5
+ require_relative "indent_navigation"
6
+ require_relative "kill_ring"
7
+ require_relative "undo_history"
8
+ require_relative "search"
9
+ require_relative "selections"
10
+ require_relative "status_text"
11
+ require_relative "vibe_state"
12
+
13
+ # Namespace for the Kward CLI agent runtime.
14
+ module Kward
15
+ # Interactive terminal UI used by the CLI frontend.
16
+ class PromptInterface
17
+ # Mutable state for the built-in composer file editor.
18
+ class EditorState
19
+ attr_reader :path, :display_path, :language, :original_content, :original_digest, :original_mtime, :original_size
20
+ attr_reader :buffer, :undo_stack, :redo_stack, :kill_buffer, :kill_ring, :last_yank_range, :last_yank_index
21
+ attr_accessor :viewport_row, :viewport_column, :status, :overwrite_confirmed, :quit_confirmed, :search_active, :search_query, :search_direction, :new_file, :editor_mode, :emacs_pending, :readonly, :diff_view
22
+
23
+ def initialize(path:, content:, new_file: false, editor_mode: "modern", readonly: false, diff_view: false, virtual: false, display_path: nil, language: nil)
24
+ @path = virtual ? nil : path.to_s
25
+ @display_path = display_path.to_s.empty? ? path.to_s : display_path.to_s
26
+ @language = language&.to_sym
27
+ @new_file = new_file
28
+ @readonly = readonly
29
+ @diff_view = diff_view
30
+ @virtual = virtual == true
31
+ @file_marker = EditorFileMarker.new(path: @path || @display_path, content: content, new_file: new_file || virtual?)
32
+ @original_content = @file_marker.content
33
+ @original_digest = @file_marker.digest
34
+ @original_mtime = @file_marker.mtime
35
+ @original_size = @file_marker.size
36
+ @text_buffer = EditorBuffer.new(@original_content)
37
+ @buffer = @text_buffer.text
38
+ @cursor = 0
39
+ @viewport_row = 0
40
+ @viewport_column = 0
41
+ @status = nil
42
+ @overwrite_confirmed = false
43
+ @quit_confirmed = false
44
+ @search = EditorSearch.new
45
+ @search_active = @search.active?
46
+ @search_query = @search.query
47
+ @search_direction = @search.direction
48
+ @kill_state = EditorKillRing.new
49
+ @kill_buffer = @kill_state.kill_buffer
50
+ @selections = EditorSelections.new(cursor: @cursor, buffer_length: @buffer.length)
51
+ sync_selection_state
52
+ @editor_mode = normalize_editor_mode(editor_mode)
53
+ @emacs_pending = nil
54
+ @kill_ring = @kill_state.kill_ring
55
+ @last_yank_range = @kill_state.last_yank_range
56
+ @last_yank_index = @kill_state.last_yank_index
57
+ @vibe_state = VibeEditorState.new(editor_mode: @editor_mode)
58
+ sync_vibe_state
59
+ @undo_history = EditorUndoHistory.new
60
+ @undo_stack = @undo_history.undo_stack
61
+ @redo_stack = @undo_history.redo_stack
62
+ @status = default_status
63
+ end
64
+
65
+ def initialize_copy(other)
66
+ super
67
+ @path = other.path&.dup
68
+ @display_path = other.display_path.dup
69
+ @language = other.language
70
+ @virtual = other.virtual?
71
+ @original_content = other.original_content.dup
72
+ @file_marker = EditorFileMarker.new(path: @path || @display_path, content: @original_content, new_file: other.new_file || @virtual)
73
+ @original_digest = other.original_digest.dup
74
+ @original_mtime = other.original_mtime
75
+ @original_size = other.original_size
76
+ @text_buffer = EditorBuffer.new(other.buffer)
77
+ @buffer = @text_buffer.text
78
+ @status = other.status.dup
79
+ @search = EditorSearch.new(direction: other.search_direction)
80
+ @search_active = other.search_active
81
+ @search_query = other.search_query.dup
82
+ @search_direction = other.search_direction
83
+ @kill_state = EditorKillRing.new(
84
+ kill_buffer: other.kill_buffer.dup,
85
+ kill_ring: other.kill_ring.map(&:dup),
86
+ last_yank_range: other.last_yank_range&.dup,
87
+ last_yank_index: other.last_yank_index
88
+ )
89
+ @kill_buffer = @kill_state.kill_buffer
90
+ @quit_confirmed = other.quit_confirmed
91
+ @viewport_column = other.viewport_column
92
+ @selections = EditorSelections.new(
93
+ cursor: @cursor,
94
+ buffer_length: @buffer.length,
95
+ anchor: other.selection_anchor,
96
+ secondary: other.selections.drop(1).map(&:dup)
97
+ )
98
+ sync_selection_state
99
+ @editor_mode = other.editor_mode.dup
100
+ @emacs_pending = other.emacs_pending&.dup
101
+ @kill_ring = @kill_state.kill_ring
102
+ @last_yank_range = @kill_state.last_yank_range
103
+ @last_yank_index = @kill_state.last_yank_index
104
+ @vibe_state = VibeEditorState.copy(other.vibe_state)
105
+ sync_vibe_state
106
+ @undo_history = EditorUndoHistory.new
107
+ other.undo_stack.each { |entry| @undo_history.undo_stack << duplicate_editor_snapshot(entry) }
108
+ other.redo_stack.each { |entry| @undo_history.redo_stack << duplicate_editor_snapshot(entry) }
109
+ @undo_stack = @undo_history.undo_stack
110
+ @redo_stack = @undo_history.redo_stack
111
+ @readonly = other.readonly
112
+ @diff_view = other.diff_view
113
+ end
114
+
115
+ attr_reader :vibe_state
116
+
117
+ def buffer=(value)
118
+ @text_buffer.text = value
119
+ @buffer = @text_buffer.text
120
+ sync_selection_state if @selections
121
+ end
122
+
123
+ def undo_stack=(value)
124
+ @undo_stack = value
125
+ @undo_history = EditorUndoHistory.new(undo_stack: @undo_stack, redo_stack: @redo_stack)
126
+ end
127
+
128
+ def redo_stack=(value)
129
+ @redo_stack = value
130
+ @undo_history = EditorUndoHistory.new(undo_stack: @undo_stack, redo_stack: @redo_stack)
131
+ end
132
+
133
+ def kill_buffer=(value)
134
+ @kill_state.kill_buffer = value
135
+ sync_kill_state
136
+ end
137
+
138
+ def kill_ring=(value)
139
+ @kill_state.kill_ring = value
140
+ sync_kill_state
141
+ end
142
+
143
+ def last_yank_range=(value)
144
+ @kill_state.last_yank_range = value
145
+ sync_kill_state
146
+ end
147
+
148
+ def last_yank_index=(value)
149
+ @kill_state.last_yank_index = value
150
+ sync_kill_state
151
+ end
152
+
153
+ def vibe_mode
154
+ @vibe_state.mode
155
+ end
156
+
157
+ def vibe_mode=(value)
158
+ @vibe_state.mode = value
159
+ sync_vibe_state
160
+ end
161
+
162
+ def vibe_pending
163
+ @vibe_state.pending
164
+ end
165
+
166
+ def vibe_pending=(value)
167
+ @vibe_state.pending = value
168
+ sync_vibe_state
169
+ end
170
+
171
+ def vibe_command
172
+ @vibe_state.command
173
+ end
174
+
175
+ def vibe_command=(value)
176
+ @vibe_state.command = value
177
+ sync_vibe_state
178
+ end
179
+
180
+ def vibe_last_change
181
+ @vibe_state.last_change
182
+ end
183
+
184
+ def vibe_last_change=(value)
185
+ @vibe_state.last_change = value
186
+ sync_vibe_state
187
+ end
188
+
189
+ def vibe_last_find
190
+ @vibe_state.last_find
191
+ end
192
+
193
+ def vibe_last_find=(value)
194
+ @vibe_state.last_find = value
195
+ sync_vibe_state
196
+ end
197
+
198
+ def vibe_last_visual_selection
199
+ @vibe_state.last_visual_selection
200
+ end
201
+
202
+ def vibe_last_visual_selection=(value)
203
+ @vibe_state.last_visual_selection = value
204
+ sync_vibe_state
205
+ end
206
+
207
+ def vibe_visual_block_insert
208
+ @vibe_state.visual_block_insert
209
+ end
210
+
211
+ def vibe_visual_block_insert=(value)
212
+ @vibe_state.visual_block_insert = value
213
+ sync_vibe_state
214
+ end
215
+
216
+ def vibe_marks
217
+ @vibe_state.marks
218
+ end
219
+
220
+ def vibe_marks=(value)
221
+ @vibe_state.marks = value
222
+ sync_vibe_state
223
+ end
224
+
225
+ def vibe_registers
226
+ @vibe_state.registers
227
+ end
228
+
229
+ def vibe_registers=(value)
230
+ @vibe_state.registers = value
231
+ sync_vibe_state
232
+ end
233
+
234
+ def vibe_macros
235
+ @vibe_state.macros
236
+ end
237
+
238
+ def vibe_macros=(value)
239
+ @vibe_state.macros = value
240
+ sync_vibe_state
241
+ end
242
+
243
+ def vibe_recording_macro
244
+ @vibe_state.recording_macro
245
+ end
246
+
247
+ def vibe_recording_macro=(value)
248
+ @vibe_state.recording_macro = value
249
+ sync_vibe_state
250
+ end
251
+
252
+ def vibe_last_macro
253
+ @vibe_state.last_macro
254
+ end
255
+
256
+ def vibe_last_macro=(value)
257
+ @vibe_state.last_macro = value
258
+ sync_vibe_state
259
+ end
260
+
261
+ def cursor
262
+ @cursor
263
+ end
264
+
265
+ def cursor=(value)
266
+ @cursor = clamp_offset(value)
267
+ @selections.cursor = @cursor
268
+ sync_selection_state
269
+ end
270
+
271
+ def selection_anchor
272
+ @selection_anchor
273
+ end
274
+
275
+ def selection_anchor=(value)
276
+ @selections.anchor = value
277
+ sync_selection_state
278
+ end
279
+
280
+ def selections
281
+ sync_selection_state
282
+ @selections.all
283
+ end
284
+
285
+ def multi_cursor?
286
+ sync_selection_state
287
+ @selections.multi_cursor?
288
+ end
289
+
290
+ def set_selections(values)
291
+ @selections.set(values)
292
+ @cursor = @selections.primary[:cursor]
293
+ sync_selection_state
294
+ end
295
+
296
+ def add_selection(anchor, cursor = anchor)
297
+ @selections.add(anchor, cursor)
298
+ sync_selection_state
299
+ end
300
+
301
+ def collapse_to_primary_selection
302
+ @selections.collapse_to_primary
303
+ sync_selection_state
304
+ end
305
+
306
+ def secondary_cursor_offsets
307
+ sync_selection_state
308
+ @selections.secondary_cursor_offsets
309
+ end
310
+
311
+ def readonly?
312
+ @readonly == true
313
+ end
314
+
315
+ def virtual?
316
+ @virtual == true
317
+ end
318
+
319
+ def bind_path(path)
320
+ @path = path.to_s
321
+ @display_path = @path
322
+ @virtual = false
323
+ @new_file = !File.exist?(@path)
324
+ @file_marker = EditorFileMarker.new(path: @path, content: @original_content, new_file: true)
325
+ end
326
+
327
+ def diff_view?
328
+ @diff_view == true
329
+ end
330
+
331
+ def modern?
332
+ @editor_mode == "modern"
333
+ end
334
+
335
+ def emacs?
336
+ @editor_mode == "emacs"
337
+ end
338
+
339
+ def vibe?
340
+ @editor_mode == "vibe"
341
+ end
342
+
343
+ def dirty?
344
+ @buffer != @original_content
345
+ end
346
+
347
+ def lines
348
+ @text_buffer.lines
349
+ end
350
+
351
+ def cursor_line_and_column
352
+ cursor_line_and_column_for(@cursor)
353
+ end
354
+
355
+ def set_cursor_line_and_column(line_index, column)
356
+ @cursor = offset_for_line_and_column(line_index, column)
357
+ end
358
+
359
+ def offset_for_line_and_column(line_index, column)
360
+ @text_buffer.offset_for_line_and_column(line_index, column)
361
+ end
362
+
363
+ def push_undo
364
+ @undo_history.push(editor_snapshot)
365
+ end
366
+
367
+ def undo
368
+ snapshot = @undo_history.undo(editor_snapshot)
369
+ unless snapshot
370
+ @status = "Already at oldest change"
371
+ return false
372
+ end
373
+
374
+ restore_editor_snapshot(snapshot)
375
+ changed!(clear_selections: false)
376
+ @status = "Undo"
377
+ true
378
+ end
379
+
380
+ def redo
381
+ snapshot = @undo_history.redo(editor_snapshot)
382
+ unless snapshot
383
+ @status = "Already at newest change"
384
+ return false
385
+ end
386
+
387
+ restore_editor_snapshot(snapshot)
388
+ changed!(clear_selections: false)
389
+ @status = "Redo"
390
+ true
391
+ end
392
+
393
+ def insert(text)
394
+ text = text.to_s
395
+ return if text.empty?
396
+ return replace_selections(text) if multi_cursor?
397
+
398
+ @text_buffer.insert(@cursor, text)
399
+ @cursor += text.length
400
+ @buffer = @text_buffer.text
401
+ sync_selection_state
402
+ changed!
403
+ end
404
+
405
+ def delete_before_cursor
406
+ return delete_before_selections if multi_cursor?
407
+ return false if @cursor.zero?
408
+
409
+ @text_buffer.delete_range(@cursor - 1, @cursor)
410
+ @cursor -= 1
411
+ @buffer = @text_buffer.text
412
+ sync_selection_state
413
+ changed!
414
+ true
415
+ end
416
+
417
+ def delete_at_cursor
418
+ return delete_at_selections if multi_cursor?
419
+ return false unless @cursor < @buffer.length
420
+
421
+ @text_buffer.delete_range(@cursor, @cursor + 1)
422
+ @buffer = @text_buffer.text
423
+ sync_selection_state
424
+ changed!
425
+ true
426
+ end
427
+
428
+ def move_left
429
+ if multi_cursor?
430
+ return move_selection_cursors { |selection| [selection[:cursor] - 1, 0].max } if extending_selections?
431
+
432
+ return move_selections { |selection| collapse_or_move_left(selection) }
433
+ end
434
+
435
+ @cursor -= 1 if @cursor.positive?
436
+ end
437
+
438
+ def move_right
439
+ if multi_cursor?
440
+ return move_selection_cursors { |selection| [selection[:cursor] + 1, @buffer.length].min } if extending_selections?
441
+
442
+ return move_selections { |selection| collapse_or_move_right(selection) }
443
+ end
444
+
445
+ @cursor += 1 if @cursor < @buffer.length
446
+ end
447
+
448
+ def move_up
449
+ if multi_cursor?
450
+ return move_selection_cursors { |selection| move_offset_vertically(selection[:cursor], -1) } if extending_selections?
451
+
452
+ return move_selections { |selection| move_offset_vertically(selection[:cursor], -1) }
453
+ end
454
+
455
+ line, column = cursor_line_and_column
456
+ set_cursor_line_and_column(line - 1, column)
457
+ end
458
+
459
+ def move_down
460
+ if multi_cursor?
461
+ return move_selection_cursors { |selection| move_offset_vertically(selection[:cursor], 1) } if extending_selections?
462
+
463
+ return move_selections { |selection| move_offset_vertically(selection[:cursor], 1) }
464
+ end
465
+
466
+ line, column = cursor_line_and_column
467
+ set_cursor_line_and_column(line + 1, column)
468
+ end
469
+
470
+ def move_line_start
471
+ if multi_cursor?
472
+ return move_selection_cursors { |selection| line_start_for_offset(selection[:cursor]) } if extending_selections?
473
+
474
+ return move_selections { |selection| line_start_for_offset(selection[:cursor]) }
475
+ end
476
+
477
+ line, = cursor_line_and_column
478
+ set_cursor_line_and_column(line, 0)
479
+ end
480
+
481
+ def move_line_first_non_blank
482
+ line, = cursor_line_and_column
483
+ move_to_line_first_non_blank(line)
484
+ end
485
+
486
+ def move_to_line_first_non_blank(line_index)
487
+ line = [[line_index.to_i, 0].max, lines.length - 1].min
488
+ column = lines[line].to_s.index(/\S/) || 0
489
+ set_cursor_line_and_column(line, column)
490
+ end
491
+
492
+ def move_line_end
493
+ if multi_cursor?
494
+ return move_selection_cursors { |selection| line_end_for_offset(selection[:cursor]) } if extending_selections?
495
+
496
+ return move_selections { |selection| line_end_for_offset(selection[:cursor]) }
497
+ end
498
+
499
+ line, = cursor_line_and_column
500
+ set_cursor_line_and_column(line, lines[line].length)
501
+ end
502
+
503
+ def page_up(rows)
504
+ if multi_cursor?
505
+ return move_selection_cursors { |selection| move_offset_vertically(selection[:cursor], -rows.to_i) } if extending_selections?
506
+
507
+ return move_selections { |selection| move_offset_vertically(selection[:cursor], -rows.to_i) }
508
+ end
509
+
510
+ line, column = cursor_line_and_column
511
+ set_cursor_line_and_column(line - rows.to_i, column)
512
+ end
513
+
514
+ def page_down(rows)
515
+ if multi_cursor?
516
+ return move_selection_cursors { |selection| move_offset_vertically(selection[:cursor], rows.to_i) } if extending_selections?
517
+
518
+ return move_selections { |selection| move_offset_vertically(selection[:cursor], rows.to_i) }
519
+ end
520
+
521
+ line, column = cursor_line_and_column
522
+ set_cursor_line_and_column(line + rows.to_i, column)
523
+ end
524
+
525
+ def move_to_previous_word
526
+ if multi_cursor?
527
+ return move_selection_cursors { |selection| previous_word_boundary(selection[:cursor]) } if extending_selections?
528
+
529
+ return move_selections { |selection| previous_word_boundary(selection[:cursor]) }
530
+ end
531
+
532
+ @cursor = previous_word_boundary(@cursor)
533
+ end
534
+
535
+ def move_to_next_word
536
+ if multi_cursor?
537
+ return move_selection_cursors { |selection| next_word_boundary(selection[:cursor]) } if extending_selections?
538
+
539
+ return move_selections { |selection| next_word_boundary(selection[:cursor]) }
540
+ end
541
+
542
+ @cursor = next_word_boundary(@cursor)
543
+ end
544
+
545
+ def move_to_word_end
546
+ if multi_cursor?
547
+ return move_selection_cursors { |selection| next_word_end(selection[:cursor]) } if extending_selections?
548
+
549
+ return move_selections { |selection| next_word_end(selection[:cursor]) }
550
+ end
551
+
552
+ @cursor = next_word_end(@cursor)
553
+ end
554
+
555
+ def move_indentation_up
556
+ if multi_cursor?
557
+ return move_selection_cursors { |selection| indentation_offset_for(selection[:cursor], :up) } if extending_selections?
558
+
559
+ return move_selections { |selection| indentation_offset_for(selection[:cursor], :up) }
560
+ end
561
+
562
+ line, column = cursor_line_and_column
563
+ target_line = previous_indentation_line(line, indentation_level_for_line(line))
564
+ move_to_indentation_line(target_line, column)
565
+ end
566
+
567
+ def move_indentation_down
568
+ if multi_cursor?
569
+ return move_selection_cursors { |selection| indentation_offset_for(selection[:cursor], :down) } if extending_selections?
570
+
571
+ return move_selections { |selection| indentation_offset_for(selection[:cursor], :down) }
572
+ end
573
+
574
+ line, column = cursor_line_and_column
575
+ target_line = next_indentation_line(line, indentation_level_for_line(line))
576
+ move_to_indentation_line(target_line, column)
577
+ end
578
+
579
+ def move_indentation_right
580
+ if multi_cursor?
581
+ return move_selection_cursors { |selection| indentation_right_offset_for(selection[:cursor]) } if extending_selections?
582
+
583
+ return move_selections { |selection| indentation_right_offset_for(selection[:cursor]) }
584
+ end
585
+
586
+ line, column = cursor_line_and_column
587
+ indentation = indentation_level_for_line(line)
588
+ if column < indentation
589
+ set_cursor_line_and_column(line, indentation)
590
+ else
591
+ move_to_word_end
592
+ end
593
+ end
594
+
595
+ def delete_word_before_cursor
596
+ return apply_selection_edits { |selection| selection[:anchor] = previous_word_boundary(selection_range_for(selection)[0]); "" } if multi_cursor?
597
+
598
+ kill_range(previous_word_boundary(@cursor), @cursor)
599
+ end
600
+
601
+ def delete_word_after_cursor
602
+ return apply_selection_edits { |selection| selection[:cursor] = next_word_boundary(selection_range_for(selection)[1]); "" } if multi_cursor?
603
+
604
+ kill_range(@cursor, next_word_boundary(@cursor))
605
+ end
606
+
607
+ def kill_line_before_cursor
608
+ return apply_selection_edits { |selection| selection[:anchor] = line_start_for_offset(selection_range_for(selection)[0]); "" } if multi_cursor?
609
+
610
+ kill_range(current_line_start, @cursor)
611
+ end
612
+
613
+ def kill_line_after_cursor
614
+ if multi_cursor?
615
+ return apply_selection_edits do |selection|
616
+ range = selection_range_for(selection)
617
+ selection[:cursor] = line_end_for_offset(range[1])
618
+ selection[:cursor] += 1 if range[0] == selection[:cursor] && selection[:cursor] < @buffer.length
619
+ ""
620
+ end
621
+ end
622
+
623
+ if current_line_empty?
624
+ kill_range(empty_line_start, empty_line_end)
625
+ else
626
+ kill_range(@cursor, current_line_end)
627
+ end
628
+ end
629
+
630
+ def yank_kill_buffer
631
+ replace_selections(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
632
+ end
633
+
634
+ def push_kill(text)
635
+ return false unless @kill_state.push(text)
636
+
637
+ sync_kill_state
638
+ true
639
+ end
640
+
641
+ def yank_from_kill_ring
642
+ text = @kill_state.first_yank
643
+ return false unless text
644
+
645
+ start_index = @cursor
646
+ insert(text)
647
+ @kill_state.record_yank(start_index, @cursor)
648
+ sync_kill_state
649
+ true
650
+ end
651
+
652
+ def yank_pop
653
+ yank = @kill_state.next_yank_pop
654
+ return false unless yank
655
+
656
+ start_index, end_index = yank[:range]
657
+ text = yank[:text]
658
+ replace_range(start_index, end_index, text)
659
+ @cursor = start_index + text.length
660
+ @kill_state.record_yank_pop(start_index, @cursor)
661
+ sync_kill_state
662
+ true
663
+ end
664
+
665
+ def begin_selection
666
+ self.selection_anchor = @cursor
667
+ @status = "Selection started"
668
+ end
669
+
670
+ def clear_selection
671
+ @selections.clear
672
+ sync_selection_state
673
+ end
674
+
675
+ def selection_active?
676
+ return false if @selection_anchor.nil?
677
+ return true if vibe? && %w[visual visual_line visual_block].include?(@vibe_mode)
678
+
679
+ @selection_anchor != @cursor
680
+ end
681
+
682
+ def selection_range
683
+ return visual_line_selection_range if vibe? && @vibe_mode == "visual_line" && selection_active?
684
+ return visual_character_selection_range if vibe? && @vibe_mode == "visual" && selection_active?
685
+ return visual_block_selection_ranges.first if vibe? && @vibe_mode == "visual_block" && selection_active?
686
+ return nil unless primary_selection_active?
687
+
688
+ [@selection_anchor, @cursor].minmax
689
+ end
690
+
691
+ def selection_ranges
692
+ if vibe? && %w[visual visual_line visual_block].include?(@vibe_mode) && selection_active?
693
+ return visual_block_selection_ranges if @vibe_mode == "visual_block"
694
+
695
+ return [selection_range]
696
+ end
697
+
698
+ selections.filter_map do |selection|
699
+ range = selection_range_for(selection)
700
+ range if range[0] != range[1]
701
+ end
702
+ end
703
+
704
+ def visual_character_selection_range
705
+ start_index, end_index = [@selection_anchor, @cursor].minmax
706
+ [start_index, [end_index + 1, @buffer.length].min]
707
+ end
708
+
709
+ def visual_line_selection_range
710
+ anchor_line, = cursor_line_and_column_for(@selection_anchor)
711
+ cursor_line, = cursor_line_and_column
712
+ start_line, end_line = [anchor_line, cursor_line].minmax
713
+ start_index, = line_range(start_line)
714
+ _, end_index = line_range(end_line)
715
+ [start_index, end_index]
716
+ end
717
+
718
+ def visual_block_selection_ranges
719
+ anchor_line, anchor_column = cursor_line_and_column_for(@selection_anchor)
720
+ cursor_line, cursor_column = cursor_line_and_column
721
+ start_line, end_line = [anchor_line, cursor_line].minmax
722
+ start_column, end_column = [anchor_column, cursor_column].minmax
723
+ (start_line..end_line).map do |line_index|
724
+ line_start = line_start_offset(line_index)
725
+ line_length = lines[line_index].to_s.length
726
+ range_start = line_start + [start_column, line_length].min
727
+ range_end = line_start + [end_column + 1, line_length].min
728
+ [range_start, range_end]
729
+ end
730
+ end
731
+
732
+ def selected_text
733
+ if vibe? && @vibe_mode == "visual_block"
734
+ return selection_ranges.map { |range| @buffer[range[0]...range[1]].to_s }.join("\n")
735
+ end
736
+
737
+ ranges = selection_ranges
738
+ return "" if ranges.empty?
739
+
740
+ ranges.map { |range| @buffer[range[0]...range[1]].to_s }.join("\n")
741
+ end
742
+
743
+ def cursor_line_and_column_for(offset)
744
+ before_cursor = @buffer[0...offset].to_s
745
+ [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
746
+ end
747
+
748
+ def line_start_offset(line_index)
749
+ values = lines
750
+ line_index = [[line_index.to_i, 0].max, values.length - 1].min
751
+ values.first(line_index).sum { |line| line.length + 1 }
752
+ end
753
+
754
+ def line_range(line_index)
755
+ start_index = line_start_offset(line_index)
756
+ end_index = start_index + lines[line_index].to_s.length
757
+ end_index += 1 if end_index < @buffer.length
758
+ [start_index, end_index]
759
+ end
760
+
761
+ def word_range_at(offset)
762
+ return nil if @buffer.empty?
763
+
764
+ index = [[offset.to_i, 0].max, @buffer.length - 1].min
765
+ return nil if word_separator?(@buffer[index])
766
+
767
+ start_index = index
768
+ start_index -= 1 while start_index.positive? && !word_separator?(@buffer[start_index - 1])
769
+ end_index = index + 1
770
+ end_index += 1 while end_index < @buffer.length && !word_separator?(@buffer[end_index])
771
+ [start_index, end_index]
772
+ end
773
+
774
+ def current_line_range
775
+ line, = cursor_line_and_column
776
+ line_range(line)
777
+ end
778
+
779
+ def move_file_start
780
+ @cursor = 0
781
+ end
782
+
783
+ def move_file_end
784
+ @cursor = @buffer.length
785
+ end
786
+
787
+ def replace_range(start_index, end_index, text)
788
+ start_index, = @text_buffer.replace_range(start_index, end_index, text)
789
+ @buffer = @text_buffer.text
790
+ @cursor = [start_index, @buffer.length].min
791
+ changed!
792
+ end
793
+
794
+ def replace_selections(text)
795
+ apply_selection_edits { |_selection| text.to_s }
796
+ end
797
+
798
+ def delete_before_selections
799
+ apply_selection_edits do |selection|
800
+ range = selection_range_for(selection)
801
+ if range[0] != range[1]
802
+ ""
803
+ elsif range[0].positive?
804
+ selection[:anchor] = range[0] - 1
805
+ ""
806
+ end
807
+ end
808
+ end
809
+
810
+ def delete_at_selections
811
+ apply_selection_edits do |selection|
812
+ range = selection_range_for(selection)
813
+ if range[0] != range[1]
814
+ ""
815
+ elsif range[1] < @buffer.length
816
+ selection[:cursor] = range[1] + 1
817
+ ""
818
+ end
819
+ end
820
+ end
821
+
822
+ def copy_range(start_index, end_index)
823
+ start_index, end_index = [start_index, end_index].minmax
824
+ self.kill_buffer = @buffer[start_index...end_index].to_s
825
+ end
826
+
827
+ def copy_for_kill_ring(start_index, end_index)
828
+ start_index, end_index = [start_index, end_index].minmax
829
+ push_kill(@buffer[start_index...end_index].to_s)
830
+ end
831
+
832
+ def cut_range(start_index, end_index)
833
+ kill_range(start_index, end_index)
834
+ end
835
+
836
+ def add_next_occurrence_selection
837
+ range = selection_range || word_range_at(@cursor)
838
+ unless range
839
+ @status = "No word under cursor"
840
+ return false
841
+ end
842
+
843
+ query = @buffer[range[0]...range[1]].to_s
844
+ if query.empty?
845
+ @status = "No word under cursor"
846
+ return false
847
+ end
848
+
849
+ existing_ranges = selection_ranges
850
+ existing_ranges = [range] if existing_ranges.empty?
851
+ start_after = existing_ranges.map(&:last).max || range[1]
852
+ if selection_range.nil?
853
+ set_selections([{ anchor: range[0], cursor: range[1] }])
854
+ @status = "Selected: #{query}"
855
+ return true
856
+ end
857
+
858
+ match = next_occurrence_range(query, start_after, existing_ranges)
859
+ unless match
860
+ @status = "No more matches: #{query}"
861
+ return false
862
+ end
863
+
864
+ add_selection(match[0], match[1])
865
+ @status = "Added cursor for: #{query}"
866
+ true
867
+ end
868
+
869
+ def add_vertical_cursor(direction)
870
+ source = direction == :up ? selections.min_by { |selection| selection[:cursor] } : selections.max_by { |selection| selection[:cursor] }
871
+ line, column = cursor_line_and_column_for(source[:cursor])
872
+ target_line = direction == :up ? line - 1 : line + 1
873
+ if target_line.negative? || target_line >= lines.length
874
+ @status = direction == :up ? "No line above" : "No line below"
875
+ return false
876
+ end
877
+
878
+ target_column = [column, lines[target_line].to_s.length].min
879
+ offset = line_start_offset(target_line) + target_column
880
+ add_selection(offset, offset)
881
+ @status = direction == :up ? "Added cursor above" : "Added cursor below"
882
+ true
883
+ end
884
+
885
+ def selection_to_line_start_cursors
886
+ range = selection_range
887
+ unless range
888
+ @status = "No selection"
889
+ return false
890
+ end
891
+
892
+ start_line, = cursor_line_and_column_for(range[0])
893
+ end_line, end_column = cursor_line_and_column_for(range[1])
894
+ end_line -= 1 if end_column.zero? && end_line > start_line
895
+ set_selections((start_line..end_line).map do |line_index|
896
+ offset = line_start_offset(line_index)
897
+ { anchor: offset, cursor: offset }
898
+ end)
899
+ @status = "Created #{selections.length} cursors"
900
+ true
901
+ end
902
+
903
+ def extending_selections
904
+ previous = @extending_selections
905
+ @extending_selections = true
906
+ yield
907
+ ensure
908
+ @extending_selections = previous
909
+ end
910
+
911
+ def begin_search(direction = :forward)
912
+ @status = @search.begin(direction)
913
+ sync_search_state
914
+ true
915
+ end
916
+
917
+ def cancel_search
918
+ @status = @search.cancel
919
+ sync_search_state
920
+ true
921
+ end
922
+
923
+ def append_search(text)
924
+ @status = @search.append(text)
925
+ sync_search_state
926
+ true
927
+ end
928
+
929
+ def delete_search_character
930
+ @status = @search.delete_character
931
+ sync_search_state
932
+ true
933
+ end
934
+
935
+ def confirm_search
936
+ apply_search_result(@search.confirm(buffer: @buffer, cursor: @cursor))
937
+ end
938
+
939
+ def repeat_search(direction = @search_direction, query = @search_query)
940
+ apply_search_result(@search.repeat(buffer: @buffer, cursor: @cursor, direction: direction, query: query))
941
+ end
942
+
943
+ def word_under_cursor
944
+ return "" if @buffer.empty?
945
+
946
+ index = [[@cursor, 0].max, @buffer.length - 1].min
947
+ index -= 1 while index.positive? && word_separator?(@buffer[index])
948
+ return "" if word_separator?(@buffer[index])
949
+
950
+ start_index = index
951
+ start_index -= 1 while start_index.positive? && !word_separator?(@buffer[start_index - 1])
952
+ end_index = index + 1
953
+ end_index += 1 while end_index < @buffer.length && !word_separator?(@buffer[end_index])
954
+ @buffer[start_index...end_index].to_s
955
+ end
956
+
957
+ def refresh_after_save(content)
958
+ @new_file = false
959
+ @virtual = false
960
+ @display_path = @path.to_s
961
+ @file_marker.refresh(content)
962
+ @original_content = @file_marker.content
963
+ @original_digest = @file_marker.digest
964
+ @original_mtime = @file_marker.mtime
965
+ @original_size = @file_marker.size
966
+ @overwrite_confirmed = false
967
+ @quit_confirmed = false
968
+ @status = "Saved #{@path}"
969
+ end
970
+
971
+ def file_changed_on_disk?
972
+ return false if virtual?
973
+
974
+ @file_marker.changed_on_disk?(new_file: new_file)
975
+ end
976
+
977
+ private
978
+
979
+ def clamp_offset(value)
980
+ [[value.to_i, 0].max, @buffer.length].min
981
+ end
982
+
983
+ def primary_selection
984
+ sync_selection_state
985
+ @selections.primary
986
+ end
987
+
988
+ def primary_selection_active?
989
+ sync_selection_state
990
+ @selections.primary_active?(vibe_visual: vibe? && %w[visual visual_line visual_block].include?(@vibe_mode))
991
+ end
992
+
993
+ def selection_range_for(selection)
994
+ @selections.range_for(selection)
995
+ end
996
+
997
+ def editor_snapshot
998
+ { buffer: @buffer.dup, selections: selections }
999
+ end
1000
+
1001
+ def restore_editor_snapshot(snapshot)
1002
+ self.buffer = snapshot[:buffer].to_s
1003
+ if snapshot[:selections]
1004
+ set_selections(snapshot[:selections])
1005
+ else
1006
+ set_selections([{ anchor: nil, cursor: [snapshot[:cursor].to_i, @buffer.length].min }])
1007
+ clear_selection
1008
+ end
1009
+ end
1010
+
1011
+ def duplicate_editor_snapshot(snapshot)
1012
+ duplicate = { buffer: snapshot[:buffer].to_s.dup }
1013
+ if snapshot[:selections]
1014
+ duplicate[:selections] = snapshot[:selections].map(&:dup)
1015
+ else
1016
+ duplicate[:cursor] = snapshot[:cursor]
1017
+ end
1018
+ duplicate
1019
+ end
1020
+
1021
+ def apply_selection_edits
1022
+ edits = selections.filter_map do |selection|
1023
+ edit_selection = selection.dup
1024
+ replacement = yield(edit_selection)
1025
+ next if replacement.nil?
1026
+
1027
+ range = selection_range_for(edit_selection)
1028
+ { start: range[0], end: range[1], text: replacement.to_s }
1029
+ end
1030
+ return false if edits.empty?
1031
+
1032
+ new_selections = []
1033
+ edits.sort_by { |edit| edit[:start] }.reverse_each do |edit|
1034
+ delta = edit[:text].length - (edit[:end] - edit[:start])
1035
+ new_selections.each do |selection|
1036
+ selection[:anchor] += delta if selection[:anchor] >= edit[:end]
1037
+ selection[:cursor] += delta if selection[:cursor] >= edit[:end]
1038
+ end
1039
+ @text_buffer.replace_range(edit[:start], edit[:end], edit[:text])
1040
+ @buffer = @text_buffer.text
1041
+ sync_selection_state
1042
+ cursor = edit[:start] + edit[:text].length
1043
+ new_selections << { anchor: cursor, cursor: cursor }
1044
+ end
1045
+ changed!(clear_selections: false)
1046
+ set_selections(new_selections.sort_by { |selection| [selection[:cursor], selection[:anchor]] })
1047
+ true
1048
+ end
1049
+
1050
+ def next_occurrence_range(query, start_after, existing_ranges)
1051
+ ranges = occurrence_ranges(query, start_after) + occurrence_ranges(query, 0, limit: start_after)
1052
+ ranges.find { |range| existing_ranges.none? { |existing| existing == range } }
1053
+ end
1054
+
1055
+ def occurrence_ranges(query, start_at, limit: @buffer.length)
1056
+ ranges = []
1057
+ index = @buffer.index(query, start_at)
1058
+ while index && index < limit
1059
+ range = [index, index + query.length]
1060
+ ranges << range if range[1] <= limit
1061
+ index = @buffer.index(query, index + query.length)
1062
+ end
1063
+ ranges
1064
+ end
1065
+
1066
+ def kill_range(start_index, end_index)
1067
+ return false if start_index == end_index
1068
+
1069
+ push_kill(@buffer[start_index...end_index].to_s)
1070
+ @text_buffer.delete_range(start_index, end_index)
1071
+ @buffer = @text_buffer.text
1072
+ @cursor = start_index
1073
+ sync_selection_state
1074
+ changed!
1075
+ true
1076
+ end
1077
+
1078
+ def current_line_start
1079
+ line_start_for_offset(@cursor)
1080
+ end
1081
+
1082
+ def current_line_end
1083
+ line_end_for_offset(@cursor)
1084
+ end
1085
+
1086
+ def line_start_for_offset(offset)
1087
+ @buffer.rindex("\n", offset.to_i - 1)&.+(1) || 0
1088
+ end
1089
+
1090
+ def line_end_for_offset(offset)
1091
+ @buffer.index("\n", offset.to_i) || @buffer.length
1092
+ end
1093
+
1094
+ def move_selections
1095
+ set_selections(selections.map do |selection|
1096
+ offset = clamp_offset(yield(selection))
1097
+ { anchor: offset, cursor: offset }
1098
+ end)
1099
+ end
1100
+
1101
+ def move_selection_cursors
1102
+ set_selections(selections.map do |selection|
1103
+ { anchor: selection[:anchor], cursor: clamp_offset(yield(selection)) }
1104
+ end)
1105
+ end
1106
+
1107
+ def extending_selections?
1108
+ @extending_selections == true
1109
+ end
1110
+
1111
+ def collapse_or_move_left(selection)
1112
+ range = selection_range_for(selection)
1113
+ return range[0] if range[0] != range[1]
1114
+
1115
+ [selection[:cursor] - 1, 0].max
1116
+ end
1117
+
1118
+ def collapse_or_move_right(selection)
1119
+ range = selection_range_for(selection)
1120
+ return range[1] if range[0] != range[1]
1121
+
1122
+ [selection[:cursor] + 1, @buffer.length].min
1123
+ end
1124
+
1125
+ def move_offset_vertically(offset, rows)
1126
+ line, column = cursor_line_and_column_for(offset)
1127
+ offset_for_line_and_column(line + rows.to_i, column)
1128
+ end
1129
+
1130
+ def indentation_offset_for(offset, direction)
1131
+ line, column = cursor_line_and_column_for(offset)
1132
+ target_line = if direction == :up
1133
+ previous_indentation_line(line, indentation_level_for_line(line))
1134
+ else
1135
+ next_indentation_line(line, indentation_level_for_line(line))
1136
+ end
1137
+ return offset if target_line.nil?
1138
+
1139
+ offset_for_line_and_column(target_line, column)
1140
+ end
1141
+
1142
+ def indentation_right_offset_for(offset)
1143
+ line, column = cursor_line_and_column_for(offset)
1144
+ indentation = indentation_level_for_line(line)
1145
+ return offset_for_line_and_column(line, indentation) if column < indentation
1146
+
1147
+ next_word_end(offset)
1148
+ end
1149
+
1150
+ def current_line_empty?
1151
+ current_line_start == @cursor && current_line_end == @cursor
1152
+ end
1153
+
1154
+ def empty_line_start
1155
+ current_line_end == @buffer.length && @cursor.positive? ? @cursor - 1 : @cursor
1156
+ end
1157
+
1158
+ def empty_line_end
1159
+ current_line_end < @buffer.length ? current_line_end + 1 : current_line_end
1160
+ end
1161
+
1162
+ def previous_word_boundary(index)
1163
+ TextBoundary.previous_word_boundary(@buffer, index)
1164
+ end
1165
+
1166
+ def next_word_boundary(index)
1167
+ TextBoundary.next_word_boundary(@buffer, index)
1168
+ end
1169
+
1170
+ def next_word_end(index)
1171
+ return 0 if @buffer.empty?
1172
+
1173
+ cursor = [[index.to_i, 0].max, @buffer.length - 1].min
1174
+ cursor += 1 if cursor < @buffer.length - 1 && !word_separator?(@buffer[cursor])
1175
+ cursor += 1 while cursor < @buffer.length && word_separator?(@buffer[cursor])
1176
+ cursor += 1 while cursor < @buffer.length - 1 && !word_separator?(@buffer[cursor + 1])
1177
+ cursor
1178
+ end
1179
+
1180
+ def indentation_level_for_line(line_index)
1181
+ indent_navigation.indentation_level_for_line(line_index)
1182
+ end
1183
+
1184
+ def empty_line?(line_index)
1185
+ indent_navigation.empty_line?(line_index)
1186
+ end
1187
+
1188
+ def move_to_indentation_line(line_index, column)
1189
+ return if line_index.nil?
1190
+
1191
+ set_cursor_line_and_column(line_index, column)
1192
+ end
1193
+
1194
+ def next_indentation_line(current_line, current_indentation)
1195
+ indent_navigation.next_line(current_line, current_indentation)
1196
+ end
1197
+
1198
+ def previous_indentation_line(current_line, current_indentation)
1199
+ indent_navigation.previous_line(current_line, current_indentation)
1200
+ end
1201
+
1202
+ def indent_navigation
1203
+ EditorIndentNavigation.new(lines)
1204
+ end
1205
+
1206
+ def word_separator?(char)
1207
+ TextBoundary.word_separator?(char)
1208
+ end
1209
+
1210
+ def sync_kill_state
1211
+ @kill_buffer = @kill_state.kill_buffer
1212
+ @kill_ring = @kill_state.kill_ring
1213
+ @last_yank_range = @kill_state.last_yank_range
1214
+ @last_yank_index = @kill_state.last_yank_index
1215
+ end
1216
+
1217
+ def sync_selection_state
1218
+ @selections.buffer_length = @buffer.length
1219
+ @selections.cursor = @cursor
1220
+ @selection_anchor = @selections.anchor
1221
+ end
1222
+
1223
+ def sync_search_state
1224
+ @search_active = @search.active?
1225
+ @search_query = @search.query
1226
+ @search_direction = @search.direction
1227
+ end
1228
+
1229
+ def sync_vibe_state
1230
+ @vibe_mode = @vibe_state.mode
1231
+ @vibe_pending = @vibe_state.pending
1232
+ @vibe_command = @vibe_state.command
1233
+ @vibe_last_change = @vibe_state.last_change
1234
+ @vibe_last_find = @vibe_state.last_find
1235
+ @vibe_last_visual_selection = @vibe_state.last_visual_selection
1236
+ @vibe_visual_block_insert = @vibe_state.visual_block_insert
1237
+ @vibe_marks = @vibe_state.marks
1238
+ @vibe_registers = @vibe_state.registers
1239
+ @vibe_macros = @vibe_state.macros
1240
+ @vibe_recording_macro = @vibe_state.recording_macro
1241
+ @vibe_last_macro = @vibe_state.last_macro
1242
+ end
1243
+
1244
+ def apply_search_result(result)
1245
+ @cursor = result[:cursor] if result[:cursor]
1246
+ @status = result[:status]
1247
+ sync_search_state
1248
+ result[:found]
1249
+ end
1250
+
1251
+ def normalize_editor_mode(value)
1252
+ EditorMode.normalize(value)
1253
+ end
1254
+
1255
+ def default_status
1256
+ EditorStatusText.default(readonly: readonly?, editor_mode: @editor_mode)
1257
+ end
1258
+
1259
+ def changed!(clear_selections: true)
1260
+ @overwrite_confirmed = false
1261
+ @quit_confirmed = false
1262
+ if clear_selections
1263
+ @selections.clear
1264
+ sync_selection_state
1265
+ end
1266
+ @kill_state.clear_last_yank
1267
+ sync_kill_state
1268
+ end
1269
+ end
1270
+ end
1271
+ end