kward 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,1018 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Built-in composer file editor behavior.
6
+ module EditorController
7
+ private
8
+
9
+ def editor_active?
10
+ !@editor_state.nil?
11
+ end
12
+
13
+ def open_selected_file_in_editor(fallback_to_typed_path: false)
14
+ path = selected_file_open_path
15
+ if path
16
+ opened = open_editor(path)
17
+ add_history(history_file_open_command(path)) if opened
18
+ return opened
19
+ end
20
+ return false unless fallback_to_typed_path
21
+
22
+ open_typed_file_path_in_editor
23
+ end
24
+
25
+ def open_typed_file_path_in_editor
26
+ file_open = active_file_open
27
+ return false unless file_open
28
+ if file_open[:query].empty?
29
+ @file_editor_open_status = "Type a file path after $"
30
+ return false
31
+ end
32
+
33
+ opened = open_editor(file_open[:query], allow_new: true)
34
+ add_history(history_file_open_command(file_open[:query])) if opened
35
+ opened
36
+ end
37
+
38
+ def history_file_open_command(path)
39
+ full_path = File.expand_path(path.to_s, Dir.pwd)
40
+ relative_path = Pathname.new(full_path).relative_path_from(Pathname.new(File.expand_path(Dir.pwd))).to_s
41
+ "$#{relative_path}"
42
+ rescue ArgumentError
43
+ "$#{path}"
44
+ end
45
+
46
+ def open_editor(path, allow_new: false, base_dir: Dir.pwd, restrict_to_workspace: true)
47
+ full_path = File.expand_path(path.to_s, base_dir)
48
+ root = File.expand_path(Dir.pwd)
49
+ if restrict_to_workspace && !(full_path == root || full_path.start_with?("#{root}/"))
50
+ @file_editor_open_status = "Cannot edit file outside workspace"
51
+ return false
52
+ end
53
+ if File.exist?(full_path) && !File.file?(full_path)
54
+ @file_editor_open_status = "Cannot edit non-file path: #{path}"
55
+ return false
56
+ end
57
+ unless File.exist?(full_path)
58
+ unless allow_new
59
+ @file_editor_open_status = "Cannot edit missing file: #{path}"
60
+ return false
61
+ end
62
+ parent = File.dirname(full_path)
63
+ unless Dir.exist?(parent)
64
+ @file_editor_open_status = "Cannot create file; parent directory is missing"
65
+ return false
66
+ end
67
+ end
68
+
69
+ @editor_state = EditorState.new(path: full_path, content: File.exist?(full_path) ? File.read(full_path) : "", new_file: !File.exist?(full_path), editor_mode: current_editor_mode)
70
+ @prompt_label = "Edit>"
71
+ self.composer_input = ""
72
+ self.composer_cursor = 0
73
+ @composer.clear_attachments
74
+ @pending_keys.clear
75
+ @file_overlay_dismissed_token = nil
76
+ @file_open_dismissed_token = nil
77
+ @asking = true
78
+ set_editor_bar_cursor_locked if current_editor_bar_cursor?
79
+ enable_editor_mouse_reporting
80
+ true
81
+ rescue StandardError => e
82
+ @file_editor_open_status = "Cannot edit #{path}: #{e.message}"
83
+ false
84
+ end
85
+
86
+ def open_diff_viewer(path, content)
87
+ @editor_state = EditorState.new(path: path.to_s, content: content.to_s, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: true)
88
+ @prompt_label = "Diff>"
89
+ self.composer_input = ""
90
+ self.composer_cursor = 0
91
+ @composer.clear_attachments
92
+ @pending_keys.clear
93
+ @asking = true
94
+ enable_editor_mouse_reporting
95
+ true
96
+ end
97
+
98
+ def current_editor_mode
99
+ return normalize_editor_mode(@editor_mode_source.call) if @editor_mode_source.respond_to?(:call)
100
+
101
+ @editor_mode
102
+ rescue StandardError
103
+ @editor_mode
104
+ end
105
+
106
+ def current_editor_soft_wrap?
107
+ return @editor_soft_wrap_source.call != false if @editor_soft_wrap_source.respond_to?(:call)
108
+
109
+ @editor_soft_wrap != false
110
+ rescue StandardError
111
+ @editor_soft_wrap != false
112
+ end
113
+
114
+ def current_editor_bar_cursor?
115
+ return @editor_bar_cursor_source.call != false if @editor_bar_cursor_source.respond_to?(:call)
116
+
117
+ @editor_bar_cursor != false
118
+ rescue StandardError
119
+ @editor_bar_cursor != false
120
+ end
121
+
122
+ def current_editor_line_numbers
123
+ return normalize_editor_line_numbers(@editor_line_numbers_source.call) if @editor_line_numbers_source.respond_to?(:call)
124
+
125
+ normalize_editor_line_numbers(@editor_line_numbers)
126
+ rescue StandardError
127
+ normalize_editor_line_numbers(@editor_line_numbers)
128
+ end
129
+
130
+ def close_editor
131
+ disable_editor_mouse_reporting(force: true)
132
+ restore_editor_cursor_shape_locked
133
+ @editor_text_width = nil
134
+ @editor_state = nil
135
+ @prompt_label = "You>"
136
+ self.composer_input = ""
137
+ self.composer_cursor = 0
138
+ restore_project_browser_after_editor_close
139
+ @asking = true
140
+ end
141
+
142
+ def handle_editor_key(key)
143
+ return if key.nil?
144
+ mouse_result = handle_editor_mouse_key(key)
145
+ return mouse_result unless mouse_result == false
146
+ return handle_readonly_editor_key(key) if @editor_state&.readonly?
147
+ return handle_vibe_key(key) if @editor_state&.vibe?
148
+ return handle_emacs_key(key) if @editor_state&.emacs?
149
+ return handle_modern_key(key) if @editor_state&.modern?
150
+ return if handle_editor_bracketed_paste_key(key)
151
+
152
+ csi_result = handle_editor_csi_u_key(key)
153
+ return csi_result unless csi_result == false
154
+
155
+ shift_result = handle_editor_shift_navigation_key(key)
156
+ return shift_result unless shift_result == false
157
+
158
+ binding_result = handle_editor_key_binding(key)
159
+ return binding_result unless binding_result == false
160
+
161
+ editor_tab_result = handle_editor_tab_key(key)
162
+ return editor_tab_result unless editor_tab_result == false
163
+
164
+ tab_result = handle_tab_key_binding(key)
165
+ return tab_result unless tab_result == false
166
+
167
+ return true if handle_bundled_key(key) { |token| handle_editor_key(token) }
168
+
169
+ case key
170
+ when "\n", "\r"
171
+ return editor_search_confirm if editor_search_active?
172
+ clear_editor_selection_before_edit
173
+ editor_insert_newline
174
+ when "\t"
175
+ editor_insert_tab unless editor_search_active?
176
+ when "\b", "\x7F"
177
+ editor_search_active? ? editor_search_delete_character : delete_editor_selection || editor_delete_before_cursor
178
+ when "\x03"
179
+ return editor_search_cancel if editor_search_active?
180
+ when "\e"
181
+ return editor_search_cancel if editor_search_active?
182
+ return @editor_state.clear_selection if @editor_state.selection_active?
183
+ when "\x11"
184
+ quit_editor
185
+ when "\x13"
186
+ save_editor
187
+ when "/"
188
+ clear_editor_selection_before_edit unless editor_search_active?
189
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
190
+ else
191
+ key_name = key_name_for(key)
192
+ named_result = handle_editor_named_key(key_name) if key_name
193
+ return named_result unless named_result == false || named_result.nil?
194
+
195
+ if editor_search_active?
196
+ editor_search_append(key) if printable_key?(key)
197
+ elsif printable_key?(key)
198
+ editor_insert_printable(key)
199
+ end
200
+ end
201
+ end
202
+
203
+ def handle_editor_csi_u_key(key)
204
+ sequence = parse_csi_u_key(key)
205
+ return false unless sequence
206
+
207
+ handle_parsed_editor_csi_u_key(sequence)
208
+ end
209
+
210
+ def handle_parsed_editor_csi_u_key(sequence)
211
+ code = sequence[:code]
212
+ modifier = sequence[:modifier]
213
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
214
+
215
+ binding_result = handle_editor_modified_csi_u_key(code, modifier)
216
+ return binding_result unless binding_result == false
217
+ text = csi_u_printable_text(sequence)
218
+ return editor_insert_csi_u_text(text) if text
219
+ return true if csi_u_text_field?(sequence)
220
+
221
+ case code
222
+ when 9
223
+ return false if editor_search_active?
224
+ return false if ctrl_modifier?(modifier) || alt_modifier?(modifier) || super_modifier?(modifier)
225
+
226
+ shift_modifier?(modifier) ? editor_outdent_tab : editor_insert_tab
227
+ when 13
228
+ clear_editor_selection_before_edit unless editor_search_active?
229
+ editor_search_active? ? editor_search_confirm : editor_insert_newline
230
+ when 27
231
+ editor_search_active? ? editor_search_cancel : @editor_state.clear_selection
232
+ when 8, 127
233
+ editor_search_active? ? editor_search_delete_character : delete_editor_selection || editor_delete_before_cursor
234
+ nil
235
+ when 4
236
+ delete_editor_selection || @editor_state.delete_at_cursor unless editor_search_active?
237
+ nil
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def editor_insert_csi_u_text(text)
244
+ if editor_search_active?
245
+ editor_search_append(text)
246
+ else
247
+ editor_insert_printable(text)
248
+ end
249
+ end
250
+
251
+ def handle_editor_mouse_key(key)
252
+ event = parse_editor_mouse_key(key)
253
+ return false unless event
254
+
255
+ queue_pending_keys(event[:remaining]) unless event[:remaining].empty?
256
+ case event[:code]
257
+ when 64
258
+ scroll_editor_up(editor_mouse_scroll_rows)
259
+ when 65
260
+ scroll_editor_down(editor_mouse_scroll_rows)
261
+ else
262
+ if event[:drag]
263
+ handle_editor_mouse_drag(event)
264
+ elsif event[:button].zero?
265
+ event[:release] ? finish_editor_mouse_drag : handle_editor_mouse_press(event)
266
+ else
267
+ true
268
+ end
269
+ end
270
+ end
271
+
272
+ def parse_editor_mouse_key(key)
273
+ match = key.to_s.match(/\A\e\[<(\d+);(\d+);(\d+)([Mm])/)
274
+ return nil unless match
275
+
276
+ code = match[1].to_i
277
+ {
278
+ code: code,
279
+ button: code & 3,
280
+ column: match[2].to_i,
281
+ row: match[3].to_i,
282
+ action: match[4],
283
+ release: match[4] == "m",
284
+ drag: (code & 32).positive?,
285
+ remaining: key.to_s[match[0].length..].to_s
286
+ }
287
+ end
288
+
289
+ def handle_editor_mouse_press(event)
290
+ position = editor_position_for_mouse_event(event)
291
+ return true unless position
292
+
293
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
294
+ click_count = editor_mouse_click_count(event, now)
295
+ case click_count
296
+ when 3..Float::INFINITY
297
+ range = select_editor_line_at(position[:line])
298
+ @editor_mouse_drag_anchor = range[0]
299
+ @editor_mouse_dragging = true
300
+ when 2
301
+ range = select_editor_word_at(position[:offset])
302
+ if range
303
+ @editor_mouse_drag_anchor = range[0]
304
+ @editor_mouse_dragging = true
305
+ else
306
+ finish_editor_mouse_drag
307
+ end
308
+ else
309
+ @editor_state.clear_selection
310
+ @editor_state.cursor = position[:offset]
311
+ @editor_mouse_drag_anchor = position[:offset]
312
+ @editor_mouse_dragging = true
313
+ end
314
+ @editor_last_click = { time: now, column: event[:column], row: event[:row], count: click_count }
315
+ true
316
+ end
317
+
318
+ def handle_editor_mouse_drag(event)
319
+ return true unless @editor_mouse_dragging
320
+
321
+ position = editor_drag_position_for_mouse_event(event)
322
+ return true unless position
323
+
324
+ @editor_state.selection_anchor = @editor_mouse_drag_anchor
325
+ @editor_state.cursor = position[:offset]
326
+ true
327
+ end
328
+
329
+ def finish_editor_mouse_drag
330
+ @editor_mouse_dragging = false
331
+ @editor_mouse_drag_anchor = nil
332
+ true
333
+ end
334
+
335
+ def editor_mouse_click_count(event, now)
336
+ return 1 unless editor_repeated_click?(event, now)
337
+
338
+ @editor_last_click[:count].to_i + 1
339
+ end
340
+
341
+ def editor_repeated_click?(event, now)
342
+ return false unless @editor_last_click
343
+ return false unless now - @editor_last_click[:time] <= 0.5
344
+
345
+ (@editor_last_click[:column] - event[:column]).abs <= 1 && (@editor_last_click[:row] - event[:row]).abs <= 1
346
+ end
347
+
348
+ def select_editor_word_at(offset)
349
+ range = @editor_state.word_range_at(offset)
350
+ return @editor_state.clear_selection unless range
351
+
352
+ @editor_state.selection_anchor = range[0]
353
+ @editor_state.cursor = range[1]
354
+ range
355
+ end
356
+
357
+ def select_editor_line_at(line_index)
358
+ range = @editor_state.line_range(line_index)
359
+ @editor_state.selection_anchor = range[0]
360
+ @editor_state.cursor = range[1]
361
+ range
362
+ end
363
+
364
+ def editor_drag_position_for_mouse_event(event)
365
+ scroll_editor_horizontally_for_drag(event)
366
+ top = editor_mouse_content_top_row
367
+ bottom = top + editor_visible_line_count - 1
368
+ if event[:row] < top
369
+ scroll_editor_up(editor_mouse_scroll_rows)
370
+ return editor_edge_position_for_mouse_event(event, @editor_state.viewport_row)
371
+ elsif event[:row] > bottom
372
+ scroll_editor_down(editor_mouse_scroll_rows)
373
+ return editor_edge_position_for_mouse_event(event, @editor_state.viewport_row + editor_visible_line_count - 1)
374
+ end
375
+
376
+ editor_position_for_mouse_event(event)
377
+ end
378
+
379
+ def editor_position_for_mouse_event(event)
380
+ row_offset = event[:row] - editor_mouse_content_top_row
381
+ return nil if row_offset.negative? || row_offset >= editor_visible_line_count
382
+
383
+ if current_editor_soft_wrap?
384
+ editor_wrapped_position_for_mouse(event, row_offset)
385
+ else
386
+ line_index = @editor_state.viewport_row + row_offset
387
+ editor_position_for_line_and_column(line_index, editor_mouse_column_for_event(event))
388
+ end
389
+ end
390
+
391
+ def editor_wrapped_position_for_mouse(event, row_offset)
392
+ editor_wrapped_position_for_visual_row(event, @editor_state.viewport_row + row_offset)
393
+ end
394
+
395
+ def editor_edge_position_for_mouse_event(event, row_index)
396
+ if current_editor_soft_wrap?
397
+ editor_wrapped_position_for_visual_row(event, row_index)
398
+ else
399
+ editor_position_for_line_and_column(row_index, editor_mouse_column_for_event(event))
400
+ end
401
+ end
402
+
403
+ def editor_wrapped_position_for_visual_row(event, row_index)
404
+ visual_row = editor_visual_rows(current_editor_text_width)[row_index]
405
+ return nil unless visual_row
406
+
407
+ column = visual_row[:column_offset] + editor_mouse_column_for_event(event)
408
+ editor_position_for_line_and_column(visual_row[:line_index], column)
409
+ end
410
+
411
+ def editor_position_for_line_and_column(line_index, column)
412
+ lines = @editor_state.lines
413
+ line_index = [[line_index.to_i, 0].max, lines.length - 1].min
414
+ column = [[column.to_i, 0].max, lines[line_index].to_s.length].min
415
+ @editor_state.set_cursor_line_and_column(line_index, column)
416
+ { line: line_index, column: column, offset: @editor_state.cursor }
417
+ end
418
+
419
+ def editor_bottom_mouse_line_index
420
+ [@editor_state.viewport_row + editor_visible_line_count - 1, @editor_state.lines.length - 1].min
421
+ end
422
+
423
+ def scroll_editor_horizontally_for_drag(event)
424
+ return if current_editor_soft_wrap?
425
+
426
+ if event[:column] < editor_mouse_text_left_column
427
+ @editor_state.viewport_column = [@editor_state.viewport_column.to_i - editor_mouse_scroll_rows, 0].max
428
+ elsif event[:column] > editor_mouse_text_right_column
429
+ @editor_state.viewport_column = @editor_state.viewport_column.to_i + editor_mouse_scroll_rows
430
+ end
431
+ end
432
+
433
+ def editor_mouse_column_for_event(event)
434
+ column = [event[:column] - editor_mouse_text_left_column, 0].max
435
+ current_editor_soft_wrap? ? column : column + @editor_state.viewport_column.to_i
436
+ end
437
+
438
+ def editor_mouse_text_left_column
439
+ 3 + editor_line_number_gutter_width
440
+ end
441
+
442
+ def editor_mouse_text_right_column
443
+ editor_mouse_text_left_column + current_editor_text_width - 1
444
+ end
445
+
446
+ def editor_mouse_content_top_row
447
+ 3
448
+ end
449
+
450
+ def handle_editor_shift_navigation_key(key)
451
+ return false if editor_search_active?
452
+
453
+ case key
454
+ when "\e[1;2D", "\e[2D"
455
+ editor_extending_selection { @editor_state.move_left }
456
+ when "\e[1;2C", "\e[2C"
457
+ editor_extending_selection { @editor_state.move_right }
458
+ when "\e[1;2A", "\e[2A"
459
+ editor_extending_selection { editor_move_up }
460
+ when "\e[1;2B", "\e[2B"
461
+ editor_extending_selection { editor_move_down }
462
+ else
463
+ false
464
+ end
465
+ end
466
+
467
+ def handle_editor_key_binding(key)
468
+ case key
469
+ when "\x01"
470
+ @editor_state.move_line_start unless editor_search_active?
471
+ when "\x02"
472
+ @editor_state.move_left unless editor_search_active?
473
+ when "\x04"
474
+ @editor_state.delete_at_cursor unless editor_search_active?
475
+ when "\x05"
476
+ @editor_state.move_line_end unless editor_search_active?
477
+ when "\x06"
478
+ @editor_state.move_right unless editor_search_active?
479
+ when "\x00"
480
+ @editor_state.begin_selection unless editor_search_active?
481
+ when "\x0B"
482
+ @editor_state.kill_line_after_cursor unless editor_search_active?
483
+ when "\x0E"
484
+ editor_move_down unless editor_search_active?
485
+ when "\x10"
486
+ editor_move_up unless editor_search_active?
487
+ when "\x15"
488
+ @editor_state.kill_line_before_cursor unless editor_search_active?
489
+ when "\x17"
490
+ @editor_state.delete_word_before_cursor unless editor_search_active?
491
+ when "\x19"
492
+ editor_selection_active? ? copy_editor_selection : @editor_state.yank_kill_buffer unless editor_search_active?
493
+ when "\e[D", "\eOD"
494
+ @editor_state.move_left unless editor_search_active?
495
+ when "\e[C", "\eOC"
496
+ @editor_state.move_right unless editor_search_active?
497
+ when "\e[H", "\eOH", "\e[1~", "\e[7~"
498
+ @editor_state.move_line_start unless editor_search_active?
499
+ when "\e[F", "\eOF", "\e[4~", "\e[8~"
500
+ @editor_state.move_line_end unless editor_search_active?
501
+ when "\e[3~"
502
+ delete_editor_selection || @editor_state.delete_at_cursor unless editor_search_active?
503
+ when "\eb", "\eB"
504
+ @editor_state.move_to_previous_word unless editor_search_active?
505
+ when "\ef", "\eF"
506
+ @editor_state.move_to_next_word unless editor_search_active?
507
+ when "\ed", "\eD"
508
+ @editor_state.delete_word_after_cursor unless editor_search_active?
509
+ when "\e\b", "\e\x7F"
510
+ @editor_state.delete_word_before_cursor unless editor_search_active?
511
+ else
512
+ handle_editor_modified_ansi_key(key) || false
513
+ end
514
+ end
515
+
516
+ def handle_editor_modified_csi_u_key(code, modifier)
517
+ return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
518
+
519
+ normalized_code = code.to_i.chr.downcase.ord rescue code
520
+ if ctrl_modifier?(modifier)
521
+ case normalized_code
522
+ when 32
523
+ @editor_state.begin_selection unless editor_search_active?
524
+ when 97
525
+ @editor_state.move_line_start unless editor_search_active?
526
+ when 98
527
+ @editor_state.move_left unless editor_search_active?
528
+ when 99
529
+ editor_search_cancel if editor_search_active?
530
+ when 100
531
+ @editor_state.delete_at_cursor unless editor_search_active?
532
+ when 101
533
+ @editor_state.move_line_end unless editor_search_active?
534
+ when 102
535
+ @editor_state.move_right unless editor_search_active?
536
+ when 107
537
+ @editor_state.kill_line_after_cursor unless editor_search_active?
538
+ when 110
539
+ editor_move_down unless editor_search_active?
540
+ when 112
541
+ editor_move_up unless editor_search_active?
542
+ when 113
543
+ quit_editor
544
+ when 115
545
+ save_editor
546
+ when 117
547
+ @editor_state.kill_line_before_cursor unless editor_search_active?
548
+ when 119
549
+ @editor_state.delete_word_before_cursor unless editor_search_active?
550
+ when 121
551
+ editor_selection_active? ? copy_editor_selection : @editor_state.yank_kill_buffer unless editor_search_active?
552
+ else
553
+ false
554
+ end
555
+ elsif alt_modifier?(modifier)
556
+ case normalized_code
557
+ when 98
558
+ @editor_state.move_to_previous_word unless editor_search_active?
559
+ when 100
560
+ @editor_state.delete_word_after_cursor unless editor_search_active?
561
+ when 102
562
+ @editor_state.move_to_next_word unless editor_search_active?
563
+ else
564
+ false
565
+ end
566
+ else
567
+ false
568
+ end
569
+ end
570
+
571
+ def handle_editor_modified_ansi_key(key)
572
+ sequence = parse_modified_ansi_key(key)
573
+ return false unless sequence
574
+
575
+ case sequence[:type]
576
+ when :cursor
577
+ return false unless alt_modifier?(sequence[:modifier])
578
+
579
+ case sequence[:final]
580
+ when "C"
581
+ @editor_state.move_to_next_word unless editor_search_active?
582
+ when "D"
583
+ @editor_state.move_to_previous_word unless editor_search_active?
584
+ when "F"
585
+ @editor_state.move_line_end unless editor_search_active?
586
+ when "H"
587
+ @editor_state.move_line_start unless editor_search_active?
588
+ else
589
+ false
590
+ end
591
+ when :delete
592
+ if alt_modifier?(sequence[:modifier])
593
+ @editor_state.delete_word_after_cursor unless editor_search_active?
594
+ else
595
+ @editor_state.delete_at_cursor unless editor_search_active?
596
+ end
597
+ else
598
+ false
599
+ end
600
+ end
601
+
602
+ def handle_editor_bracketed_paste_key(key)
603
+ paste = read_bracketed_paste(key)
604
+ return false unless paste
605
+
606
+ @editor_state.insert(normalize_paste(paste[:content])) unless editor_search_active?
607
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
608
+ true
609
+ end
610
+
611
+ def ctrl_code(code)
612
+ value = code.to_i
613
+ return value if value < 32
614
+
615
+ value.chr.downcase.ord
616
+ rescue StandardError
617
+ code
618
+ end
619
+
620
+ def handle_editor_named_key(key_name)
621
+ return false unless key_name
622
+
623
+ if editor_search_active?
624
+ case key_name
625
+ when :return, :enter
626
+ editor_search_confirm
627
+ when :backspace
628
+ editor_search_delete_character
629
+ else
630
+ false
631
+ end
632
+ else
633
+ case key_name
634
+ when :return, :enter
635
+ editor_insert_newline
636
+ when :backspace
637
+ delete_editor_selection || editor_delete_before_cursor
638
+ when :delete
639
+ delete_editor_selection || @editor_state.delete_at_cursor
640
+ when :left
641
+ @editor_state.move_left
642
+ when :right
643
+ @editor_state.move_right
644
+ when :up
645
+ editor_move_up
646
+ when :down
647
+ editor_move_down
648
+ when :home
649
+ @editor_state.move_line_start
650
+ when :end
651
+ @editor_state.move_line_end
652
+ when :pageup
653
+ scroll_editor_up(editor_scroll_page_rows)
654
+ when :pagedown
655
+ scroll_editor_down(editor_scroll_page_rows)
656
+ else
657
+ false
658
+ end
659
+ end
660
+ end
661
+
662
+ def handle_readonly_editor_key(key)
663
+ return if handle_readonly_bracketed_paste_key(key)
664
+
665
+ return true if handle_bundled_key(key) { |token| handle_readonly_editor_key(token) }
666
+
667
+ key_name = key_name_for(key)
668
+ named_result = handle_readonly_named_key(key_name) if key_name
669
+ return named_result unless named_result == false || named_result.nil?
670
+
671
+ case key
672
+ when "\x11"
673
+ close_editor
674
+ when "\x06"
675
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
676
+ when "\x03"
677
+ editor_search_cancel if editor_search_active?
678
+ when "/"
679
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
680
+ when "\b", "\x7F"
681
+ editor_search_delete_character if editor_search_active?
682
+ when "\n", "\r"
683
+ editor_search_confirm if editor_search_active?
684
+ when "\e"
685
+ editor_search_active? ? editor_search_cancel : close_editor
686
+ else
687
+ csi_result = handle_readonly_csi_u_key(key)
688
+ return csi_result unless csi_result == false
689
+
690
+ if editor_search_active?
691
+ editor_search_append(key) if printable_key?(key)
692
+ elsif printable_key?(key)
693
+ @editor_state.status = "Read-only diff"
694
+ end
695
+ end
696
+ end
697
+
698
+ def handle_readonly_csi_u_key(key)
699
+ sequence = parse_csi_u_key(key)
700
+ return false unless sequence
701
+
702
+ code = sequence[:code]
703
+ modifier = sequence[:modifier]
704
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
705
+
706
+ if ctrl_modifier?(modifier) && ctrl_code(code) == 102
707
+ return editor_search_active? ? editor_search_append(key) : editor_search_begin
708
+ end
709
+
710
+ case code
711
+ when 13
712
+ editor_search_confirm if editor_search_active?
713
+ when 27
714
+ editor_search_active? ? editor_search_cancel : close_editor
715
+ when 8, 127
716
+ editor_search_delete_character if editor_search_active?
717
+ else
718
+ return false unless editor_search_active?
719
+
720
+ text = csi_u_printable_text(sequence)
721
+ return true if text.nil? && csi_u_text_field?(sequence)
722
+ return false unless text
723
+
724
+ editor_search_append(text)
725
+ end
726
+ end
727
+
728
+ def handle_readonly_bracketed_paste_key(key)
729
+ paste = read_bracketed_paste(key)
730
+ return false unless paste
731
+
732
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
733
+ @editor_state.status = "Read-only diff" unless editor_search_active?
734
+ true
735
+ end
736
+
737
+ def handle_readonly_named_key(key_name)
738
+ return false unless key_name
739
+
740
+ if editor_search_active?
741
+ case key_name
742
+ when :return, :enter
743
+ editor_search_confirm
744
+ when :backspace
745
+ editor_search_delete_character
746
+ else
747
+ false
748
+ end
749
+ else
750
+ case key_name
751
+ when :left
752
+ @editor_state.move_left
753
+ when :right
754
+ @editor_state.move_right
755
+ when :up
756
+ editor_move_up
757
+ when :down
758
+ editor_move_down
759
+ when :home
760
+ @editor_state.move_line_start
761
+ when :end
762
+ @editor_state.move_line_end
763
+ when :pageup
764
+ scroll_editor_up(editor_scroll_page_rows)
765
+ when :pagedown
766
+ scroll_editor_down(editor_scroll_page_rows)
767
+ else
768
+ false
769
+ end
770
+ end
771
+ end
772
+
773
+ def editor_extending_selection
774
+ if @editor_state.multi_cursor?
775
+ @editor_state.extending_selections { yield }
776
+ else
777
+ @editor_state.selection_anchor ||= @editor_state.cursor
778
+ yield
779
+ end
780
+ true
781
+ end
782
+
783
+ def editor_move_up
784
+ return @editor_state.move_up unless current_editor_soft_wrap?
785
+
786
+ line, column = @editor_state.cursor_line_and_column
787
+ text_width = current_editor_text_width
788
+ row_start = editor_visual_row_start_column(line, column, text_width)
789
+ if row_start.positive?
790
+ target_column = row_start - text_width + column - row_start
791
+ return @editor_state.set_cursor_line_and_column(line, target_column)
792
+ end
793
+
794
+ @editor_state.move_up
795
+ end
796
+
797
+ def editor_move_down
798
+ return @editor_state.move_down unless current_editor_soft_wrap?
799
+
800
+ line, column = @editor_state.cursor_line_and_column
801
+ text_width = current_editor_text_width
802
+ row_start = editor_visual_row_start_column(line, column, text_width)
803
+ next_start = row_start + text_width
804
+ current_line = @editor_state.lines[line].to_s
805
+ if next_start < current_line.length
806
+ target_column = [next_start + column - row_start, current_line.length].min
807
+ return @editor_state.set_cursor_line_and_column(line, target_column)
808
+ end
809
+
810
+ @editor_state.move_down
811
+ end
812
+
813
+ def current_editor_text_width
814
+ return @editor_text_width if @editor_text_width
815
+
816
+ content_width = [screen_width - 4, 1].max
817
+ editor_text_width(content_width)
818
+ end
819
+
820
+ def sync_editor_wrap_state(text_width = current_editor_text_width)
821
+ return unless @editor_state
822
+
823
+ @editor_text_width = text_width
824
+ @editor_state.viewport_column = 0 if current_editor_soft_wrap?
825
+ text_width
826
+ end
827
+
828
+ def editor_selection_active?
829
+ @editor_state&.selection_active?
830
+ end
831
+
832
+ def clear_editor_selection_before_edit
833
+ @editor_state&.clear_selection
834
+ end
835
+
836
+ def delete_editor_selection
837
+ return false unless @editor_state.selection_ranges.any?
838
+
839
+ @editor_state.replace_selections("")
840
+ true
841
+ end
842
+
843
+ def copy_editor_selection
844
+ text = @editor_state.selected_text
845
+ return false if text.empty?
846
+
847
+ @editor_state.push_kill(text)
848
+ @output_io.print("\e]52;c;#{Base64.strict_encode64(text)}\a")
849
+ @output_io.flush if @output_io.respond_to?(:flush)
850
+ @editor_state.clear_selection
851
+ @editor_state.status = "Copied selection"
852
+ true
853
+ end
854
+
855
+ def cut_editor_selection
856
+ text = @editor_state.selected_text
857
+ return false if text.empty?
858
+
859
+ @editor_state.push_kill(text)
860
+ @editor_state.replace_selections("")
861
+ @editor_state.status = "Cut selection"
862
+ true
863
+ end
864
+
865
+ def editor_search_active?
866
+ @editor_state&.search_active
867
+ end
868
+
869
+ def editor_search_begin(direction = :forward)
870
+ @editor_state.begin_search(direction)
871
+ true
872
+ end
873
+
874
+ def editor_search_append(text)
875
+ @editor_state.append_search(text)
876
+ true
877
+ end
878
+
879
+ def editor_search_delete_character
880
+ @editor_state.delete_search_character
881
+ true
882
+ end
883
+
884
+ def editor_search_confirm
885
+ @editor_state.confirm_search
886
+ true
887
+ end
888
+
889
+ def editor_search_cancel
890
+ @editor_state.cancel_search
891
+ true
892
+ end
893
+
894
+ def editor_search_repeat(direction = nil)
895
+ direction ||= @editor_state.search_direction
896
+ @editor_state.repeat_search(direction)
897
+ true
898
+ end
899
+
900
+ def editor_search_word_under_cursor(direction = :forward)
901
+ query = @editor_state.word_under_cursor
902
+ if query.empty?
903
+ @editor_state.status = "No word under cursor"
904
+ return true
905
+ end
906
+
907
+ @editor_state.repeat_search(direction, query)
908
+ true
909
+ end
910
+
911
+ def editor_page_rows
912
+ [editor_visible_line_count, 1].max
913
+ end
914
+
915
+ def editor_scroll_page_rows
916
+ [editor_visible_line_count / 2, 1].max
917
+ end
918
+
919
+ def editor_mouse_scroll_rows
920
+ 1
921
+ end
922
+
923
+ def enable_editor_mouse_reporting
924
+ return if @editor_mouse_reporting_enabled
925
+
926
+ @output_io.print("\e[?1003h\e[?1006h")
927
+ @output_io.flush if @output_io.respond_to?(:flush)
928
+ @editor_mouse_reporting_enabled = true
929
+ end
930
+
931
+ def disable_editor_mouse_reporting(force: false)
932
+ return unless force || @editor_mouse_reporting_enabled
933
+
934
+ @output_io.print("\e[?1006l\e[?1003l")
935
+ @output_io.flush if @output_io.respond_to?(:flush)
936
+ @editor_mouse_reporting_enabled = false
937
+ end
938
+
939
+ def scroll_editor_up(rows)
940
+ visible_count = editor_visible_line_count
941
+ @editor_state.viewport_row = [@editor_state.viewport_row - rows.to_i, 0].max
942
+ keep_editor_cursor_in_view(visible_count)
943
+ end
944
+
945
+ def scroll_editor_down(rows)
946
+ visible_count = editor_visible_line_count
947
+ last_top_row = if current_editor_soft_wrap?
948
+ [editor_visual_rows(current_editor_text_width).length - visible_count, 0].max
949
+ else
950
+ [@editor_state.lines.length - visible_count, 0].max
951
+ end
952
+ @editor_state.viewport_row = [@editor_state.viewport_row + rows.to_i, last_top_row].min
953
+ keep_editor_cursor_in_view(visible_count)
954
+ end
955
+
956
+ def keep_editor_cursor_in_view(visible_count)
957
+ line, column = @editor_state.cursor_line_and_column
958
+ if current_editor_soft_wrap?
959
+ text_width = current_editor_text_width
960
+ top_row = @editor_state.viewport_row
961
+ bottom_row = top_row + visible_count - 1
962
+ while editor_visual_row_for(*@editor_state.cursor_line_and_column, text_width) > bottom_row && @editor_state.cursor.positive?
963
+ editor_move_up
964
+ end
965
+ while editor_visual_row_for(*@editor_state.cursor_line_and_column, text_width) < top_row && @editor_state.cursor < @editor_state.buffer.length
966
+ editor_move_down
967
+ end
968
+ return true
969
+ end
970
+
971
+ top_line = @editor_state.viewport_row
972
+ bottom_line = top_line + visible_count - 1
973
+
974
+ if line < top_line
975
+ @editor_state.set_cursor_line_and_column(top_line, column)
976
+ elsif line > bottom_line
977
+ @editor_state.set_cursor_line_and_column(bottom_line, column)
978
+ end
979
+ true
980
+ end
981
+
982
+ def quit_editor(message = "Unsaved changes. Press Ctrl+Q again to discard.")
983
+ return false unless @editor_state
984
+ return close_editor unless @editor_state.dirty?
985
+ return close_editor if @editor_state.quit_confirmed
986
+
987
+ @editor_state.quit_confirmed = true
988
+ @editor_state.status = message
989
+ true
990
+ end
991
+
992
+ def save_editor
993
+ return false unless @editor_state
994
+ if @editor_state.readonly?
995
+ @editor_state.status = "Read-only diff"
996
+ return true
997
+ end
998
+
999
+ if @editor_state.file_changed_on_disk? && !@editor_state.overwrite_confirmed
1000
+ @editor_state.overwrite_confirmed = true
1001
+ @editor_state.status = "File changed on disk. Press Ctrl+S again to overwrite."
1002
+ return true
1003
+ end
1004
+
1005
+ File.write(@editor_state.path, @editor_state.buffer)
1006
+ @editor_state.refresh_after_save(@editor_state.buffer)
1007
+ true
1008
+ rescue StandardError => e
1009
+ @editor_state.status = "Save failed: #{e.message}" if @editor_state
1010
+ false
1011
+ end
1012
+
1013
+ def printable_key?(key)
1014
+ key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
1015
+ end
1016
+ end
1017
+ end
1018
+ end