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,1218 @@
1
+ require "fileutils"
2
+ require_relative "../../scratchpad_runner"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Interactive terminal UI used by the CLI frontend.
7
+ class PromptInterface
8
+ # Built-in composer file editor behavior.
9
+ module EditorController
10
+ private
11
+
12
+ def editor_active?
13
+ !@editor_state.nil?
14
+ end
15
+
16
+ def open_selected_file_in_editor(fallback_to_typed_path: false)
17
+ path = selected_file_open_path
18
+ if path
19
+ opened = open_editor(path)
20
+ add_history(history_file_open_command(path)) if opened
21
+ return opened
22
+ end
23
+ return false unless fallback_to_typed_path
24
+
25
+ open_typed_file_path_in_editor
26
+ end
27
+
28
+ def open_typed_file_path_in_editor
29
+ file_open = active_file_open
30
+ return false unless file_open
31
+ if file_open[:query].empty?
32
+ @file_editor_open_status = "Type a file path after $"
33
+ return false
34
+ end
35
+
36
+ opened = open_editor(file_open[:query], allow_new: true)
37
+ add_history(history_file_open_command(file_open[:query])) if opened
38
+ opened
39
+ end
40
+
41
+ def history_file_open_command(path)
42
+ full_path = File.expand_path(path.to_s, Dir.pwd)
43
+ relative_path = Pathname.new(full_path).relative_path_from(Pathname.new(File.expand_path(Dir.pwd))).to_s
44
+ "$#{relative_path}"
45
+ rescue ArgumentError
46
+ "$#{path}"
47
+ end
48
+
49
+ def open_scratchpad(language = :text, content: "")
50
+ language = normalize_scratchpad_language(language)
51
+ @editor_state = EditorState.new(
52
+ path: scratchpad_display_path(language),
53
+ display_path: scratchpad_display_path(language),
54
+ content: content.to_s,
55
+ new_file: true,
56
+ editor_mode: current_editor_mode,
57
+ virtual: true,
58
+ language: language
59
+ )
60
+ @editor_state.status = scratchpad_status_text(language)
61
+ @prompt_label = "Edit>"
62
+ self.composer_input = ""
63
+ self.composer_cursor = 0
64
+ @composer.clear_attachments
65
+ @pending_keys.clear
66
+ @file_overlay_dismissed_token = nil
67
+ @file_open_dismissed_token = nil
68
+ @asking = true
69
+ set_editor_bar_cursor_locked if current_editor_bar_cursor?
70
+ enable_editor_mouse_reporting
71
+ true
72
+ end
73
+
74
+ def open_editor(path, allow_new: false, base_dir: Dir.pwd, restrict_to_workspace: true)
75
+ full_path = File.expand_path(path.to_s, base_dir)
76
+ root = File.expand_path(Dir.pwd)
77
+ if restrict_to_workspace && !(full_path == root || full_path.start_with?("#{root}/"))
78
+ @file_editor_open_status = "Cannot edit file outside workspace"
79
+ return false
80
+ end
81
+ if File.exist?(full_path) && !File.file?(full_path)
82
+ @file_editor_open_status = "Cannot edit non-file path: #{path}"
83
+ return false
84
+ end
85
+ unless File.exist?(full_path)
86
+ unless allow_new
87
+ @file_editor_open_status = "Cannot edit missing file: #{path}"
88
+ return false
89
+ end
90
+ parent = File.dirname(full_path)
91
+ unless Dir.exist?(parent)
92
+ @file_editor_open_status = "Cannot create file; parent directory is missing"
93
+ return false
94
+ end
95
+ end
96
+
97
+ @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)
98
+ @prompt_label = "Edit>"
99
+ self.composer_input = ""
100
+ self.composer_cursor = 0
101
+ @composer.clear_attachments
102
+ @pending_keys.clear
103
+ @file_overlay_dismissed_token = nil
104
+ @file_open_dismissed_token = nil
105
+ @asking = true
106
+ set_editor_bar_cursor_locked if current_editor_bar_cursor?
107
+ enable_editor_mouse_reporting
108
+ true
109
+ rescue StandardError => e
110
+ @file_editor_open_status = "Cannot edit #{path}: #{e.message}"
111
+ false
112
+ end
113
+
114
+ def open_diff_viewer(path, content)
115
+ @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)
116
+ @prompt_label = "Diff>"
117
+ self.composer_input = ""
118
+ self.composer_cursor = 0
119
+ @composer.clear_attachments
120
+ @pending_keys.clear
121
+ @asking = true
122
+ enable_editor_mouse_reporting
123
+ true
124
+ end
125
+
126
+ def current_editor_mode
127
+ return normalize_editor_mode(@editor_mode_source.call) if @editor_mode_source.respond_to?(:call)
128
+
129
+ @editor_mode
130
+ rescue StandardError
131
+ @editor_mode
132
+ end
133
+
134
+ def current_editor_soft_wrap?
135
+ return @editor_soft_wrap_source.call != false if @editor_soft_wrap_source.respond_to?(:call)
136
+
137
+ @editor_soft_wrap != false
138
+ rescue StandardError
139
+ @editor_soft_wrap != false
140
+ end
141
+
142
+ def current_editor_bar_cursor?
143
+ return @editor_bar_cursor_source.call != false if @editor_bar_cursor_source.respond_to?(:call)
144
+
145
+ @editor_bar_cursor != false
146
+ rescue StandardError
147
+ @editor_bar_cursor != false
148
+ end
149
+
150
+ def current_editor_line_numbers
151
+ return normalize_editor_line_numbers(@editor_line_numbers_source.call) if @editor_line_numbers_source.respond_to?(:call)
152
+
153
+ normalize_editor_line_numbers(@editor_line_numbers)
154
+ rescue StandardError
155
+ normalize_editor_line_numbers(@editor_line_numbers)
156
+ end
157
+
158
+ def close_editor
159
+ disable_editor_mouse_reporting(force: true)
160
+ restore_editor_cursor_shape_locked
161
+ @editor_text_width = nil
162
+ @editor_save_as_active = false
163
+ @editor_save_as_buffer = ""
164
+ @editor_state = nil
165
+ @prompt_label = "You>"
166
+ self.composer_input = ""
167
+ self.composer_cursor = 0
168
+ restore_project_browser_after_editor_close
169
+ @asking = true
170
+ end
171
+
172
+ def handle_editor_key(key)
173
+ return if key.nil?
174
+ mouse_result = handle_editor_mouse_key(key)
175
+ return mouse_result unless mouse_result == false
176
+ return handle_editor_save_as_key(key) if @editor_save_as_active
177
+ return handle_readonly_editor_key(key) if @editor_state&.readonly?
178
+ return handle_vibe_key(key) if @editor_state&.vibe?
179
+ return handle_emacs_key(key) if @editor_state&.emacs?
180
+ return handle_modern_key(key) if @editor_state&.modern?
181
+ return if handle_editor_bracketed_paste_key(key)
182
+
183
+ csi_result = handle_editor_csi_u_key(key)
184
+ return csi_result unless csi_result == false
185
+
186
+ shift_result = handle_editor_shift_navigation_key(key)
187
+ return shift_result unless shift_result == false
188
+
189
+ binding_result = handle_editor_key_binding(key)
190
+ return binding_result unless binding_result == false
191
+
192
+ editor_tab_result = handle_editor_tab_key(key)
193
+ return editor_tab_result unless editor_tab_result == false
194
+
195
+ tab_result = handle_tab_key_binding(key)
196
+ return tab_result unless tab_result == false
197
+
198
+ return true if handle_bundled_key(key) { |token| handle_editor_key(token) }
199
+
200
+ case key
201
+ when "\n", "\r"
202
+ return editor_search_confirm if editor_search_active?
203
+ clear_editor_selection_before_edit
204
+ editor_insert_newline
205
+ when "\t"
206
+ editor_insert_tab unless editor_search_active?
207
+ when "\b", "\x7F"
208
+ editor_search_active? ? editor_search_delete_character : delete_editor_selection || editor_delete_before_cursor
209
+ when TerminalKeys::CTRL_C
210
+ return editor_search_cancel if editor_search_active?
211
+ when "\e"
212
+ return editor_search_cancel if editor_search_active?
213
+ return @editor_state.clear_selection if @editor_state.selection_active?
214
+ when TerminalKeys::CTRL_Q
215
+ quit_editor
216
+ when TerminalKeys::CTRL_S
217
+ save_editor
218
+ when "/"
219
+ clear_editor_selection_before_edit unless editor_search_active?
220
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
221
+ else
222
+ key_name = key_name_for(key)
223
+ named_result = handle_editor_named_key(key_name) if key_name
224
+ return named_result unless named_result == false || named_result.nil?
225
+
226
+ if editor_search_active?
227
+ editor_search_append(key) if printable_key?(key)
228
+ elsif printable_key?(key)
229
+ editor_insert_printable(key)
230
+ end
231
+ end
232
+ end
233
+
234
+ def handle_editor_csi_u_key(key)
235
+ sequence = parse_csi_u_key(key)
236
+ return false unless sequence
237
+
238
+ handle_parsed_editor_csi_u_key(sequence)
239
+ end
240
+
241
+ def handle_parsed_editor_csi_u_key(sequence)
242
+ code = sequence[:code]
243
+ modifier = sequence[:modifier]
244
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
245
+
246
+ binding_result = handle_editor_modified_csi_u_key(code, modifier)
247
+ return binding_result unless binding_result == false
248
+ text = csi_u_printable_text(sequence)
249
+ return editor_insert_csi_u_text(text) if text
250
+ return true if csi_u_text_field?(sequence)
251
+
252
+ case code
253
+ when 9
254
+ return false if editor_search_active?
255
+ return false if ctrl_modifier?(modifier) || alt_modifier?(modifier) || super_modifier?(modifier)
256
+
257
+ shift_modifier?(modifier) ? editor_outdent_tab : editor_insert_tab
258
+ when 13
259
+ clear_editor_selection_before_edit unless editor_search_active?
260
+ editor_search_active? ? editor_search_confirm : editor_insert_newline
261
+ when 27
262
+ editor_search_active? ? editor_search_cancel : @editor_state.clear_selection
263
+ when 8, 127
264
+ editor_search_active? ? editor_search_delete_character : delete_editor_selection || editor_delete_before_cursor
265
+ nil
266
+ when 4
267
+ delete_editor_selection || @editor_state.delete_at_cursor unless editor_search_active?
268
+ nil
269
+ else
270
+ false
271
+ end
272
+ end
273
+
274
+ def editor_insert_csi_u_text(text)
275
+ if editor_search_active?
276
+ editor_search_append(text)
277
+ else
278
+ editor_insert_printable(text)
279
+ end
280
+ end
281
+
282
+ def handle_editor_mouse_key(key)
283
+ event = parse_editor_mouse_key(key)
284
+ return false unless event
285
+
286
+ queue_pending_keys(event[:remaining]) unless event[:remaining].empty?
287
+ case event[:code]
288
+ when 64
289
+ scroll_editor_up(editor_mouse_scroll_rows)
290
+ when 65
291
+ scroll_editor_down(editor_mouse_scroll_rows)
292
+ else
293
+ if event[:drag]
294
+ handle_editor_mouse_drag(event)
295
+ elsif event[:button].zero?
296
+ event[:release] ? finish_editor_mouse_drag : handle_editor_mouse_press(event)
297
+ else
298
+ true
299
+ end
300
+ end
301
+ end
302
+
303
+ def parse_editor_mouse_key(key)
304
+ parse_sgr_mouse_event(key)
305
+ end
306
+
307
+ def handle_editor_mouse_press(event)
308
+ position = editor_position_for_mouse_event(event)
309
+ return true unless position
310
+
311
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
312
+ click_count = editor_mouse_click_count(event, now)
313
+ case click_count
314
+ when 3..Float::INFINITY
315
+ range = select_editor_line_at(position[:line])
316
+ @editor_mouse_drag_anchor = range[0]
317
+ @editor_mouse_dragging = true
318
+ when 2
319
+ range = select_editor_word_at(position[:offset])
320
+ if range
321
+ @editor_mouse_drag_anchor = range[0]
322
+ @editor_mouse_dragging = true
323
+ else
324
+ finish_editor_mouse_drag
325
+ end
326
+ else
327
+ @editor_state.clear_selection
328
+ @editor_state.cursor = position[:offset]
329
+ @editor_mouse_drag_anchor = position[:offset]
330
+ @editor_mouse_dragging = true
331
+ end
332
+ @editor_last_click = { time: now, column: event[:column], row: event[:row], count: click_count }
333
+ true
334
+ end
335
+
336
+ def handle_editor_mouse_drag(event)
337
+ return true unless @editor_mouse_dragging
338
+
339
+ position = editor_drag_position_for_mouse_event(event)
340
+ return true unless position
341
+
342
+ @editor_state.selection_anchor = @editor_mouse_drag_anchor
343
+ @editor_state.cursor = position[:offset]
344
+ true
345
+ end
346
+
347
+ def finish_editor_mouse_drag
348
+ @editor_mouse_dragging = false
349
+ @editor_mouse_drag_anchor = nil
350
+ true
351
+ end
352
+
353
+ def editor_mouse_click_count(event, now)
354
+ return 1 unless editor_repeated_click?(event, now)
355
+
356
+ @editor_last_click[:count].to_i + 1
357
+ end
358
+
359
+ def editor_repeated_click?(event, now)
360
+ return false unless @editor_last_click
361
+ return false unless now - @editor_last_click[:time] <= 0.5
362
+
363
+ (@editor_last_click[:column] - event[:column]).abs <= 1 && (@editor_last_click[:row] - event[:row]).abs <= 1
364
+ end
365
+
366
+ def select_editor_word_at(offset)
367
+ range = @editor_state.word_range_at(offset)
368
+ return @editor_state.clear_selection unless range
369
+
370
+ @editor_state.selection_anchor = range[0]
371
+ @editor_state.cursor = range[1]
372
+ range
373
+ end
374
+
375
+ def select_editor_line_at(line_index)
376
+ range = @editor_state.line_range(line_index)
377
+ @editor_state.selection_anchor = range[0]
378
+ @editor_state.cursor = range[1]
379
+ range
380
+ end
381
+
382
+ def editor_drag_position_for_mouse_event(event)
383
+ scroll_editor_horizontally_for_drag(event)
384
+ top = editor_mouse_content_top_row
385
+ bottom = top + editor_visible_line_count - 1
386
+ if event[:row] < top
387
+ scroll_editor_up(editor_mouse_scroll_rows)
388
+ return editor_edge_position_for_mouse_event(event, @editor_state.viewport_row)
389
+ elsif event[:row] > bottom
390
+ scroll_editor_down(editor_mouse_scroll_rows)
391
+ return editor_edge_position_for_mouse_event(event, @editor_state.viewport_row + editor_visible_line_count - 1)
392
+ end
393
+
394
+ editor_position_for_mouse_event(event)
395
+ end
396
+
397
+ def editor_position_for_mouse_event(event)
398
+ row_offset = event[:row] - editor_mouse_content_top_row
399
+ return nil if row_offset.negative? || row_offset >= editor_visible_line_count
400
+
401
+ if current_editor_soft_wrap?
402
+ editor_wrapped_position_for_mouse(event, row_offset)
403
+ else
404
+ line_index = @editor_state.viewport_row + row_offset
405
+ editor_position_for_line_and_column(line_index, editor_mouse_column_for_event(event))
406
+ end
407
+ end
408
+
409
+ def editor_wrapped_position_for_mouse(event, row_offset)
410
+ editor_wrapped_position_for_visual_row(event, @editor_state.viewport_row + row_offset)
411
+ end
412
+
413
+ def editor_edge_position_for_mouse_event(event, row_index)
414
+ if current_editor_soft_wrap?
415
+ editor_wrapped_position_for_visual_row(event, row_index)
416
+ else
417
+ editor_position_for_line_and_column(row_index, editor_mouse_column_for_event(event))
418
+ end
419
+ end
420
+
421
+ def editor_wrapped_position_for_visual_row(event, row_index)
422
+ visual_row = editor_visual_rows(current_editor_text_width)[row_index]
423
+ return nil unless visual_row
424
+
425
+ column = visual_row[:column_offset] + editor_mouse_column_for_event(event)
426
+ editor_position_for_line_and_column(visual_row[:line_index], column)
427
+ end
428
+
429
+ def editor_position_for_line_and_column(line_index, column)
430
+ lines = @editor_state.lines
431
+ line_index = [[line_index.to_i, 0].max, lines.length - 1].min
432
+ column = [[column.to_i, 0].max, lines[line_index].to_s.length].min
433
+ @editor_state.set_cursor_line_and_column(line_index, column)
434
+ { line: line_index, column: column, offset: @editor_state.cursor }
435
+ end
436
+
437
+ def editor_bottom_mouse_line_index
438
+ [@editor_state.viewport_row + editor_visible_line_count - 1, @editor_state.lines.length - 1].min
439
+ end
440
+
441
+ def scroll_editor_horizontally_for_drag(event)
442
+ return if current_editor_soft_wrap?
443
+
444
+ if event[:column] < editor_mouse_text_left_column
445
+ @editor_state.viewport_column = [@editor_state.viewport_column.to_i - editor_mouse_scroll_rows, 0].max
446
+ elsif event[:column] > editor_mouse_text_right_column
447
+ @editor_state.viewport_column = @editor_state.viewport_column.to_i + editor_mouse_scroll_rows
448
+ end
449
+ end
450
+
451
+ def editor_mouse_column_for_event(event)
452
+ column = [event[:column] - editor_mouse_text_left_column, 0].max
453
+ current_editor_soft_wrap? ? column : column + @editor_state.viewport_column.to_i
454
+ end
455
+
456
+ def editor_mouse_text_left_column
457
+ 3 + editor_line_number_gutter_width
458
+ end
459
+
460
+ def editor_mouse_text_right_column
461
+ editor_mouse_text_left_column + current_editor_text_width - 1
462
+ end
463
+
464
+ def editor_mouse_content_top_row
465
+ 3
466
+ end
467
+
468
+ def handle_editor_shift_navigation_key(key)
469
+ return false if editor_search_active?
470
+
471
+ case key
472
+ when *TerminalKeys::SHIFT_LEFT
473
+ editor_extending_selection { @editor_state.move_left }
474
+ when *TerminalKeys::SHIFT_RIGHT
475
+ editor_extending_selection { @editor_state.move_right }
476
+ when *TerminalKeys::SHIFT_UP
477
+ editor_extending_selection { editor_move_up }
478
+ when *TerminalKeys::SHIFT_DOWN
479
+ editor_extending_selection { editor_move_down }
480
+ else
481
+ false
482
+ end
483
+ end
484
+
485
+ def handle_editor_key_binding(key)
486
+ case key
487
+ when TerminalKeys::CTRL_A
488
+ @editor_state.move_line_start unless editor_search_active?
489
+ when TerminalKeys::CTRL_B
490
+ @editor_state.move_left unless editor_search_active?
491
+ when TerminalKeys::CTRL_D
492
+ @editor_state.delete_at_cursor unless editor_search_active?
493
+ when TerminalKeys::CTRL_E
494
+ @editor_state.move_line_end unless editor_search_active?
495
+ when TerminalKeys::CTRL_F
496
+ @editor_state.move_right unless editor_search_active?
497
+ when TerminalKeys::CTRL_SPACE
498
+ @editor_state.begin_selection unless editor_search_active?
499
+ when TerminalKeys::CTRL_K
500
+ @editor_state.kill_line_after_cursor unless editor_search_active?
501
+ when TerminalKeys::CTRL_N
502
+ editor_move_down unless editor_search_active?
503
+ when TerminalKeys::CTRL_P
504
+ editor_move_up unless editor_search_active?
505
+ when TerminalKeys::CTRL_U
506
+ @editor_state.kill_line_before_cursor unless editor_search_active?
507
+ when TerminalKeys::CTRL_W
508
+ @editor_state.delete_word_before_cursor unless editor_search_active?
509
+ when TerminalKeys::CTRL_Y
510
+ editor_selection_active? ? copy_editor_selection : @editor_state.yank_kill_buffer unless editor_search_active?
511
+ when *TerminalKeys::LEFT
512
+ @editor_state.move_left unless editor_search_active?
513
+ when *TerminalKeys::RIGHT
514
+ @editor_state.move_right unless editor_search_active?
515
+ when *TerminalKeys::HOME
516
+ @editor_state.move_line_start unless editor_search_active?
517
+ when *TerminalKeys::END_KEY
518
+ @editor_state.move_line_end unless editor_search_active?
519
+ when *TerminalKeys::DELETE
520
+ delete_editor_selection || @editor_state.delete_at_cursor unless editor_search_active?
521
+ when "\eb", "\eB"
522
+ @editor_state.move_to_previous_word unless editor_search_active?
523
+ when "\ef", "\eF"
524
+ @editor_state.move_to_next_word unless editor_search_active?
525
+ when "\ed", "\eD"
526
+ @editor_state.delete_word_after_cursor unless editor_search_active?
527
+ when "\e\b", "\e\x7F"
528
+ @editor_state.delete_word_before_cursor unless editor_search_active?
529
+ else
530
+ handle_editor_modified_ansi_key(key) || false
531
+ end
532
+ end
533
+
534
+ def handle_editor_modified_csi_u_key(code, modifier)
535
+ return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
536
+
537
+ normalized_code = code.to_i.chr.downcase.ord rescue code
538
+ if ctrl_modifier?(modifier)
539
+ case normalized_code
540
+ when 32
541
+ @editor_state.begin_selection unless editor_search_active?
542
+ when 97
543
+ @editor_state.move_line_start unless editor_search_active?
544
+ when 98
545
+ @editor_state.move_left unless editor_search_active?
546
+ when 99
547
+ editor_search_cancel if editor_search_active?
548
+ when 100
549
+ @editor_state.delete_at_cursor unless editor_search_active?
550
+ when 101
551
+ @editor_state.move_line_end unless editor_search_active?
552
+ when 102
553
+ @editor_state.move_right unless editor_search_active?
554
+ when 107
555
+ @editor_state.kill_line_after_cursor unless editor_search_active?
556
+ when 110
557
+ editor_move_down unless editor_search_active?
558
+ when 112
559
+ editor_move_up unless editor_search_active?
560
+ when 113
561
+ quit_editor
562
+ when 115
563
+ save_editor
564
+ when 117
565
+ @editor_state.kill_line_before_cursor unless editor_search_active?
566
+ when 119
567
+ @editor_state.delete_word_before_cursor unless editor_search_active?
568
+ when 121
569
+ editor_selection_active? ? copy_editor_selection : @editor_state.yank_kill_buffer unless editor_search_active?
570
+ else
571
+ false
572
+ end
573
+ elsif alt_modifier?(modifier)
574
+ case normalized_code
575
+ when 98
576
+ @editor_state.move_to_previous_word unless editor_search_active?
577
+ when 100
578
+ @editor_state.delete_word_after_cursor unless editor_search_active?
579
+ when 102
580
+ @editor_state.move_to_next_word unless editor_search_active?
581
+ else
582
+ false
583
+ end
584
+ else
585
+ false
586
+ end
587
+ end
588
+
589
+ def handle_editor_modified_ansi_key(key)
590
+ sequence = parse_modified_ansi_key(key)
591
+ return false unless sequence
592
+
593
+ case sequence[:type]
594
+ when :cursor
595
+ return false unless alt_modifier?(sequence[:modifier])
596
+
597
+ case sequence[:final]
598
+ when "C"
599
+ @editor_state.move_to_next_word unless editor_search_active?
600
+ when "D"
601
+ @editor_state.move_to_previous_word unless editor_search_active?
602
+ when "F"
603
+ @editor_state.move_line_end unless editor_search_active?
604
+ when "H"
605
+ @editor_state.move_line_start unless editor_search_active?
606
+ else
607
+ false
608
+ end
609
+ when :delete
610
+ if alt_modifier?(sequence[:modifier])
611
+ @editor_state.delete_word_after_cursor unless editor_search_active?
612
+ else
613
+ @editor_state.delete_at_cursor unless editor_search_active?
614
+ end
615
+ else
616
+ false
617
+ end
618
+ end
619
+
620
+ def handle_editor_bracketed_paste_key(key)
621
+ handle_bracketed_paste(key) do |content|
622
+ @editor_state.insert(content) unless editor_search_active?
623
+ end
624
+ end
625
+
626
+ def ctrl_code(code)
627
+ value = code.to_i
628
+ return value if value < 32
629
+
630
+ value.chr.downcase.ord
631
+ rescue StandardError
632
+ code
633
+ end
634
+
635
+ def handle_editor_named_key(key_name)
636
+ return false unless key_name
637
+
638
+ if editor_search_active?
639
+ case key_name
640
+ when :return, :enter
641
+ editor_search_confirm
642
+ when :backspace
643
+ editor_search_delete_character
644
+ else
645
+ false
646
+ end
647
+ else
648
+ case key_name
649
+ when :return, :enter
650
+ editor_insert_newline
651
+ when :backspace
652
+ delete_editor_selection || editor_delete_before_cursor
653
+ when :delete
654
+ delete_editor_selection || @editor_state.delete_at_cursor
655
+ when :left
656
+ @editor_state.move_left
657
+ when :right
658
+ @editor_state.move_right
659
+ when :up
660
+ editor_move_up
661
+ when :down
662
+ editor_move_down
663
+ when :home
664
+ @editor_state.move_line_start
665
+ when :end
666
+ @editor_state.move_line_end
667
+ when :pageup
668
+ scroll_editor_up(editor_scroll_page_rows)
669
+ when :pagedown
670
+ scroll_editor_down(editor_scroll_page_rows)
671
+ else
672
+ false
673
+ end
674
+ end
675
+ end
676
+
677
+ def handle_readonly_editor_key(key)
678
+ return if handle_readonly_bracketed_paste_key(key)
679
+
680
+ return true if handle_bundled_key(key) { |token| handle_readonly_editor_key(token) }
681
+
682
+ key_name = key_name_for(key)
683
+ named_result = handle_readonly_named_key(key_name) if key_name
684
+ return named_result unless named_result == false || named_result.nil?
685
+
686
+ case key
687
+ when TerminalKeys::CTRL_Q
688
+ close_editor
689
+ when TerminalKeys::CTRL_F
690
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
691
+ when TerminalKeys::CTRL_C
692
+ editor_search_active? ? editor_search_cancel : copy_editor_selection
693
+ when "/"
694
+ editor_search_active? ? editor_search_append(key) : editor_search_begin
695
+ when "\b", "\x7F"
696
+ editor_search_delete_character if editor_search_active?
697
+ when "\n", "\r"
698
+ editor_search_confirm if editor_search_active?
699
+ when "\e"
700
+ editor_search_active? ? editor_search_cancel : close_editor
701
+ else
702
+ csi_result = handle_readonly_csi_u_key(key)
703
+ return csi_result unless csi_result == false
704
+
705
+ if editor_search_active?
706
+ editor_search_append(key) if printable_key?(key)
707
+ elsif printable_key?(key)
708
+ @editor_state.status = "Read-only diff"
709
+ end
710
+ end
711
+ end
712
+
713
+ def handle_readonly_csi_u_key(key)
714
+ sequence = parse_csi_u_key(key)
715
+ return false unless sequence
716
+
717
+ code = sequence[:code]
718
+ modifier = sequence[:modifier]
719
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
720
+
721
+ if ctrl_modifier?(modifier) && ctrl_code(code) == 102
722
+ return editor_search_active? ? editor_search_append(key) : editor_search_begin
723
+ end
724
+
725
+ if (ctrl_modifier?(modifier) || super_modifier?(modifier)) && ctrl_code(code) == 99
726
+ return editor_search_active? ? editor_search_cancel : copy_editor_selection
727
+ end
728
+
729
+ case code
730
+ when 13
731
+ editor_search_confirm if editor_search_active?
732
+ when 27
733
+ editor_search_active? ? editor_search_cancel : close_editor
734
+ when 8, 127
735
+ editor_search_delete_character if editor_search_active?
736
+ else
737
+ return false unless editor_search_active?
738
+
739
+ text = csi_u_printable_text(sequence)
740
+ return true if text.nil? && csi_u_text_field?(sequence)
741
+ return false unless text
742
+
743
+ editor_search_append(text)
744
+ end
745
+ end
746
+
747
+ def handle_readonly_bracketed_paste_key(key)
748
+ handle_bracketed_paste(key) do |_content|
749
+ @editor_state.status = "Read-only diff" unless editor_search_active?
750
+ end
751
+ end
752
+
753
+ def handle_readonly_named_key(key_name)
754
+ return false unless key_name
755
+
756
+ if editor_search_active?
757
+ case key_name
758
+ when :return, :enter
759
+ editor_search_confirm
760
+ when :backspace
761
+ editor_search_delete_character
762
+ else
763
+ false
764
+ end
765
+ else
766
+ case key_name
767
+ when :left
768
+ @editor_state.move_left
769
+ when :right
770
+ @editor_state.move_right
771
+ when :up
772
+ editor_move_up
773
+ when :down
774
+ editor_move_down
775
+ when :home
776
+ @editor_state.move_line_start
777
+ when :end
778
+ @editor_state.move_line_end
779
+ when :pageup
780
+ scroll_editor_up(editor_scroll_page_rows)
781
+ when :pagedown
782
+ scroll_editor_down(editor_scroll_page_rows)
783
+ else
784
+ false
785
+ end
786
+ end
787
+ end
788
+
789
+ def editor_extending_selection
790
+ if @editor_state.multi_cursor?
791
+ @editor_state.extending_selections { yield }
792
+ else
793
+ @editor_state.selection_anchor ||= @editor_state.cursor
794
+ yield
795
+ end
796
+ true
797
+ end
798
+
799
+ def editor_move_up
800
+ return @editor_state.move_up unless current_editor_soft_wrap?
801
+
802
+ line, column = @editor_state.cursor_line_and_column
803
+ text_width = current_editor_text_width
804
+ row_start = editor_visual_row_start_column(line, column, text_width)
805
+ visual_column = column - row_start
806
+ if row_start.positive?
807
+ target_column = row_start - text_width + visual_column
808
+ return @editor_state.set_cursor_line_and_column(line, target_column)
809
+ end
810
+
811
+ return @editor_state.move_up if line.zero?
812
+
813
+ previous_line = @editor_state.lines[line - 1].to_s
814
+ previous_row_start = editor_last_visual_row_start_column(previous_line, text_width)
815
+ target_column = [previous_row_start + visual_column, previous_line.length].min
816
+ @editor_state.set_cursor_line_and_column(line - 1, target_column)
817
+ end
818
+
819
+ def editor_move_down
820
+ return @editor_state.move_down unless current_editor_soft_wrap?
821
+
822
+ line, column = @editor_state.cursor_line_and_column
823
+ text_width = current_editor_text_width
824
+ row_start = editor_visual_row_start_column(line, column, text_width)
825
+ visual_column = column - row_start
826
+ next_start = row_start + text_width
827
+ current_line = @editor_state.lines[line].to_s
828
+ if next_start < current_line.length
829
+ target_column = [next_start + visual_column, current_line.length].min
830
+ return @editor_state.set_cursor_line_and_column(line, target_column)
831
+ end
832
+
833
+ return @editor_state.move_down if line >= @editor_state.lines.length - 1
834
+
835
+ next_line = @editor_state.lines[line + 1].to_s
836
+ target_column = [visual_column, next_line.length].min
837
+ @editor_state.set_cursor_line_and_column(line + 1, target_column)
838
+ end
839
+
840
+ def editor_last_visual_row_start_column(line, text_width)
841
+ length = line.to_s.length
842
+ return 0 if length.zero?
843
+
844
+ ((length - 1) / text_width) * text_width
845
+ end
846
+
847
+ def current_editor_text_width
848
+ return @editor_text_width if @editor_text_width
849
+
850
+ content_width = [screen_width - 4, 1].max
851
+ editor_text_width(content_width)
852
+ end
853
+
854
+ def sync_editor_wrap_state(text_width = current_editor_text_width)
855
+ return unless @editor_state
856
+
857
+ @editor_text_width = text_width
858
+ @editor_state.viewport_column = 0 if current_editor_soft_wrap?
859
+ text_width
860
+ end
861
+
862
+ def editor_selection_active?
863
+ @editor_state&.selection_active?
864
+ end
865
+
866
+ def clear_editor_selection_before_edit
867
+ @editor_state&.clear_selection
868
+ end
869
+
870
+ def delete_editor_selection
871
+ return false unless @editor_state.selection_ranges.any?
872
+
873
+ @editor_state.replace_selections("")
874
+ true
875
+ end
876
+
877
+ def copy_editor_selection
878
+ text = @editor_state.selected_text
879
+ return false if text.empty?
880
+
881
+ @editor_state.push_kill(text)
882
+ @output_io.print(TerminalSequences.osc52(text))
883
+ @output_io.flush if @output_io.respond_to?(:flush)
884
+ @editor_state.clear_selection
885
+ @editor_state.status = "Copied selection"
886
+ true
887
+ end
888
+
889
+ def cut_editor_selection
890
+ text = @editor_state.selected_text
891
+ return false if text.empty?
892
+
893
+ @editor_state.push_kill(text)
894
+ @editor_state.replace_selections("")
895
+ @editor_state.status = "Cut selection"
896
+ true
897
+ end
898
+
899
+ def editor_search_active?
900
+ @editor_state&.search_active
901
+ end
902
+
903
+ def editor_search_begin(direction = :forward)
904
+ @editor_state.begin_search(direction)
905
+ true
906
+ end
907
+
908
+ def editor_search_append(text)
909
+ @editor_state.append_search(text)
910
+ true
911
+ end
912
+
913
+ def editor_search_delete_character
914
+ @editor_state.delete_search_character
915
+ true
916
+ end
917
+
918
+ def editor_search_confirm
919
+ @editor_state.confirm_search
920
+ true
921
+ end
922
+
923
+ def editor_search_cancel
924
+ @editor_state.cancel_search
925
+ true
926
+ end
927
+
928
+ def editor_search_repeat(direction = nil)
929
+ direction ||= @editor_state.search_direction
930
+ @editor_state.repeat_search(direction)
931
+ true
932
+ end
933
+
934
+ def editor_search_word_under_cursor(direction = :forward)
935
+ query = @editor_state.word_under_cursor
936
+ if query.empty?
937
+ @editor_state.status = "No word under cursor"
938
+ return true
939
+ end
940
+
941
+ @editor_state.repeat_search(direction, query)
942
+ true
943
+ end
944
+
945
+ def editor_page_rows
946
+ [editor_visible_line_count, 1].max
947
+ end
948
+
949
+ def editor_scroll_page_rows
950
+ [editor_visible_line_count / 2, 1].max
951
+ end
952
+
953
+ def editor_mouse_scroll_rows
954
+ 1
955
+ end
956
+
957
+ def enable_editor_mouse_reporting
958
+ return if @editor_mouse_reporting_enabled
959
+
960
+ @output_io.print(TerminalSequences::MOUSE_REPORTING_ENABLE)
961
+ @output_io.flush if @output_io.respond_to?(:flush)
962
+ @editor_mouse_reporting_enabled = true
963
+ end
964
+
965
+ def disable_editor_mouse_reporting(force: false)
966
+ return unless force || @editor_mouse_reporting_enabled
967
+
968
+ @output_io.print(TerminalSequences::MOUSE_REPORTING_DISABLE)
969
+ @output_io.flush if @output_io.respond_to?(:flush)
970
+ @editor_mouse_reporting_enabled = false
971
+ end
972
+
973
+ def scroll_editor_up(rows)
974
+ visible_count = editor_visible_line_count
975
+ @editor_state.viewport_row = [@editor_state.viewport_row - rows.to_i, 0].max
976
+ keep_editor_cursor_in_view(visible_count)
977
+ end
978
+
979
+ def scroll_editor_down(rows)
980
+ visible_count = editor_visible_line_count
981
+ last_top_row = if current_editor_soft_wrap?
982
+ [editor_visual_rows(current_editor_text_width).length - visible_count, 0].max
983
+ else
984
+ [@editor_state.lines.length - visible_count, 0].max
985
+ end
986
+ @editor_state.viewport_row = [@editor_state.viewport_row + rows.to_i, last_top_row].min
987
+ keep_editor_cursor_in_view(visible_count)
988
+ end
989
+
990
+ def keep_editor_cursor_in_view(visible_count)
991
+ line, column = @editor_state.cursor_line_and_column
992
+ if current_editor_soft_wrap?
993
+ text_width = current_editor_text_width
994
+ top_row = @editor_state.viewport_row
995
+ bottom_row = top_row + visible_count - 1
996
+ while editor_visual_row_for(*@editor_state.cursor_line_and_column, text_width) > bottom_row && @editor_state.cursor.positive?
997
+ editor_move_up
998
+ end
999
+ while editor_visual_row_for(*@editor_state.cursor_line_and_column, text_width) < top_row && @editor_state.cursor < @editor_state.buffer.length
1000
+ editor_move_down
1001
+ end
1002
+ return true
1003
+ end
1004
+
1005
+ top_line = @editor_state.viewport_row
1006
+ bottom_line = top_line + visible_count - 1
1007
+
1008
+ if line < top_line
1009
+ @editor_state.set_cursor_line_and_column(top_line, column)
1010
+ elsif line > bottom_line
1011
+ @editor_state.set_cursor_line_and_column(bottom_line, column)
1012
+ end
1013
+ true
1014
+ end
1015
+
1016
+ def quit_editor(message = "Unsaved changes. Press Ctrl+Q again to discard.")
1017
+ return false unless @editor_state
1018
+ return close_editor unless @editor_state.dirty?
1019
+ return close_editor if @editor_state.quit_confirmed
1020
+
1021
+ @editor_state.quit_confirmed = true
1022
+ @editor_state.status = message
1023
+ true
1024
+ end
1025
+
1026
+ def save_editor(path = nil, prompt_for_path: true)
1027
+ return false unless @editor_state
1028
+ if @editor_state.readonly?
1029
+ @editor_state.status = "Read-only diff"
1030
+ return true
1031
+ end
1032
+
1033
+ if path.to_s.strip.empty? && @editor_state.path.to_s.empty?
1034
+ if prompt_for_path
1035
+ begin_editor_save_as
1036
+ return true
1037
+ end
1038
+
1039
+ @editor_state.status = "Use :w filename"
1040
+ return false
1041
+ end
1042
+
1043
+ return false if !path.to_s.strip.empty? && !bind_editor_save_path(path)
1044
+
1045
+ if @editor_state.file_changed_on_disk? && !@editor_state.overwrite_confirmed
1046
+ @editor_state.overwrite_confirmed = true
1047
+ @editor_state.status = "File changed on disk. Press Ctrl+S again to overwrite."
1048
+ return true
1049
+ end
1050
+
1051
+ parent = File.dirname(@editor_state.path)
1052
+ FileUtils.mkdir_p(parent) unless Dir.exist?(parent)
1053
+ File.write(@editor_state.path, @editor_state.buffer)
1054
+ @editor_state.refresh_after_save(@editor_state.buffer)
1055
+ true
1056
+ rescue StandardError => e
1057
+ @editor_state.status = "Save failed: #{e.message}" if @editor_state
1058
+ false
1059
+ end
1060
+
1061
+ def begin_editor_save_as
1062
+ @editor_save_as_active = true
1063
+ @editor_save_as_buffer = ""
1064
+ @editor_state.status = "Save as: "
1065
+ true
1066
+ end
1067
+
1068
+ def handle_editor_save_as_key(key)
1069
+ return true if handle_bundled_key(key) { |token| handle_editor_save_as_key(token) }
1070
+
1071
+ csi_result = handle_editor_save_as_csi_u_key(key)
1072
+ return csi_result unless csi_result == false
1073
+
1074
+ case key
1075
+ when "\n", "\r"
1076
+ finish_editor_save_as
1077
+ when "\e", TerminalKeys::CTRL_C
1078
+ cancel_editor_save_as
1079
+ when "\b", "\x7F"
1080
+ @editor_save_as_buffer = @editor_save_as_buffer.to_s[0...-1]
1081
+ update_editor_save_as_status
1082
+ else
1083
+ key_name = key_name_for(key)
1084
+ case key_name
1085
+ when :return, :enter
1086
+ finish_editor_save_as
1087
+ when :backspace
1088
+ @editor_save_as_buffer = @editor_save_as_buffer.to_s[0...-1]
1089
+ update_editor_save_as_status
1090
+ else
1091
+ if printable_key?(key)
1092
+ @editor_save_as_buffer = @editor_save_as_buffer.to_s + key
1093
+ update_editor_save_as_status
1094
+ end
1095
+ end
1096
+ end
1097
+ true
1098
+ end
1099
+
1100
+ def handle_editor_save_as_csi_u_key(key)
1101
+ sequence = parse_csi_u_key(key)
1102
+ return false unless sequence
1103
+
1104
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
1105
+ code = sequence[:code]
1106
+ modifier = sequence[:modifier]
1107
+ normalized_code = ctrl_code(code)
1108
+ if code == 13
1109
+ return finish_editor_save_as
1110
+ elsif [8, 127].include?(code)
1111
+ @editor_save_as_buffer = @editor_save_as_buffer.to_s[0...-1]
1112
+ return update_editor_save_as_status
1113
+ elsif code == 27 || (ctrl_modifier?(modifier) && normalized_code == 99)
1114
+ return cancel_editor_save_as
1115
+ end
1116
+
1117
+ text = csi_u_printable_text(sequence)
1118
+ if text
1119
+ @editor_save_as_buffer = @editor_save_as_buffer.to_s + text
1120
+ return update_editor_save_as_status
1121
+ end
1122
+
1123
+ true
1124
+ end
1125
+
1126
+ def finish_editor_save_as
1127
+ path = @editor_save_as_buffer.to_s.strip
1128
+ @editor_save_as_active = false
1129
+ @editor_save_as_buffer = ""
1130
+ if path.empty?
1131
+ @editor_state.status = "Save canceled"
1132
+ return true
1133
+ end
1134
+
1135
+ save_editor(path, prompt_for_path: false)
1136
+ end
1137
+
1138
+ def cancel_editor_save_as
1139
+ @editor_save_as_active = false
1140
+ @editor_save_as_buffer = ""
1141
+ @editor_state.status = "Save canceled"
1142
+ true
1143
+ end
1144
+
1145
+ def update_editor_save_as_status
1146
+ @editor_state.status = "Save as: #{@editor_save_as_buffer}"
1147
+ true
1148
+ end
1149
+
1150
+ def bind_editor_save_path(path)
1151
+ full_path = File.expand_path(path.to_s.strip, Dir.pwd)
1152
+ root = File.expand_path(Dir.pwd)
1153
+ unless full_path == root || full_path.start_with?("#{root}/")
1154
+ @editor_state.status = "Cannot save outside workspace"
1155
+ return false
1156
+ end
1157
+
1158
+ @editor_state.bind_path(full_path)
1159
+ @editor_syntax_language_path = nil
1160
+ @editor_indent_unit_path = nil
1161
+ true
1162
+ end
1163
+
1164
+ def run_editor_buffer
1165
+ return false unless @editor_state
1166
+
1167
+ language = @editor_state.language || editor_syntax_language
1168
+ result = ScratchpadRunner.run(language, @editor_state.buffer)
1169
+ @editor_state.replace_range(0, @editor_state.buffer.length, result.buffer)
1170
+ @editor_state.status = "Ran #{language} (exit #{result.exit_status})"
1171
+ true
1172
+ rescue StandardError => e
1173
+ @editor_state.status = "Run failed: #{e.message}" if @editor_state
1174
+ false
1175
+ end
1176
+
1177
+ def normalize_scratchpad_language(language)
1178
+ case language.to_s.strip.downcase
1179
+ when "", "text", "txt"
1180
+ :text
1181
+ when "markdown", "md"
1182
+ :markdown
1183
+ when "ruby", "rb"
1184
+ :ruby
1185
+ else
1186
+ :text
1187
+ end
1188
+ end
1189
+
1190
+ def scratchpad_display_path(language)
1191
+ case language.to_sym
1192
+ when :markdown
1193
+ "scratchpad.md"
1194
+ when :ruby
1195
+ "scratchpad.rb"
1196
+ else
1197
+ "scratchpad.txt"
1198
+ end
1199
+ end
1200
+
1201
+ def scratchpad_status_text(language)
1202
+ runnable = language.to_sym == :ruby
1203
+ case current_editor_mode
1204
+ when "vibe"
1205
+ runnable ? "NORMAL · i insert · :w filename save · :q quit · :run run" : "NORMAL · i insert · :w filename save · :q quit"
1206
+ when "emacs"
1207
+ runnable ? "C-x C-s save as · C-x C-c quit · C-r run" : "C-x C-s save as · C-x C-c quit"
1208
+ else
1209
+ runnable ? "Ctrl+S save as · Ctrl+Q quit · Ctrl+R run" : "Ctrl+S save as · Ctrl+Q quit"
1210
+ end
1211
+ end
1212
+
1213
+ def printable_key?(key)
1214
+ key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
1215
+ end
1216
+ end
1217
+ end
1218
+ end