kward 0.81.0 → 0.83.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/agent-tools.md +1 -0
  7. data/doc/composer.md +2 -2
  8. data/doc/configuration.md +18 -3
  9. data/doc/editor.md +20 -10
  10. data/doc/files.md +12 -5
  11. data/doc/permissions.md +1 -0
  12. data/doc/releasing.md +79 -38
  13. data/doc/rpc.md +2 -2
  14. data/doc/sandboxing.md +5 -1
  15. data/doc/security.md +2 -2
  16. data/doc/shell.md +42 -18
  17. data/doc/tabs.md +3 -0
  18. data/doc/usage.md +3 -2
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  21. data/lib/kward/agent.rb +1 -0
  22. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  23. data/lib/kward/cli/git.rb +5 -1
  24. data/lib/kward/cli/interactive_turn.rb +101 -19
  25. data/lib/kward/cli/rendering.rb +14 -2
  26. data/lib/kward/cli/runtime_helpers.rb +252 -46
  27. data/lib/kward/cli/settings.rb +5 -13
  28. data/lib/kward/cli/slash_commands.rb +12 -1
  29. data/lib/kward/cli/tabs.rb +71 -9
  30. data/lib/kward/cli.rb +12 -0
  31. data/lib/kward/compaction/token_estimator.rb +12 -6
  32. data/lib/kward/config_files.rb +17 -0
  33. data/lib/kward/editor_prompt.rb +46 -0
  34. data/lib/kward/editor_prompt_session.rb +28 -0
  35. data/lib/kward/ekwsh.rb +70 -10
  36. data/lib/kward/image_attachments.rb +98 -15
  37. data/lib/kward/interactive_pty_runner.rb +25 -19
  38. data/lib/kward/model/client.rb +1 -17
  39. data/lib/kward/model/model_info.rb +3 -2
  40. data/lib/kward/model/payloads.rb +0 -2
  41. data/lib/kward/persistent_shell_session.rb +750 -0
  42. data/lib/kward/project_files.rb +18 -5
  43. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  44. data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
  45. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  46. data/lib/kward/prompt_interface/editor/controller.rb +75 -6
  47. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  48. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
  50. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  51. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  52. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  53. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  54. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  55. data/lib/kward/prompt_interface/key_handler.rb +30 -0
  56. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  57. data/lib/kward/prompt_interface/project_browser.rb +192 -8
  58. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  59. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  60. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  61. data/lib/kward/prompt_interface/screen.rb +40 -20
  62. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  63. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  64. data/lib/kward/prompt_interface.rb +159 -32
  65. data/lib/kward/prompts/commands.rb +1 -0
  66. data/lib/kward/pty_output_sink.rb +45 -0
  67. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  68. data/lib/kward/rpc/auth_manager.rb +1 -1
  69. data/lib/kward/rpc/server.rb +2 -1
  70. data/lib/kward/session_catalog.rb +87 -0
  71. data/lib/kward/session_store.rb +94 -5
  72. data/lib/kward/shell_prompt.rb +50 -0
  73. data/lib/kward/shell_prompt_session.rb +58 -0
  74. data/lib/kward/terminal_image_support.rb +116 -0
  75. data/lib/kward/terminal_keys.rb +1 -0
  76. data/lib/kward/terminal_sequences.rb +43 -0
  77. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  78. data/lib/kward/tools/registry.rb +63 -5
  79. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  80. data/lib/kward/tools/run_shell_command.rb +24 -6
  81. data/lib/kward/version.rb +1 -1
  82. data/templates/default/layout/html/layout.erb +1 -1
  83. metadata +15 -4
  84. data/.github/workflows/ci.yml +0 -48
  85. data/.github/workflows/pages.yml +0 -48
@@ -8,21 +8,34 @@ module Kward
8
8
  module ProjectFiles
9
9
  module_function
10
10
 
11
- def list(root: Dir.pwd)
12
- paths = git_paths(root)
13
- paths = scanned_paths(root) if paths.empty?
11
+ def list(root: Dir.pwd, include_ignored: false)
12
+ paths = git_paths(root, include_ignored: include_ignored)
13
+ paths = scanned_paths(root) if paths.empty? && !git_repository?(root)
14
14
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
15
15
  end
16
16
 
17
- def git_paths(root)
17
+ def git_paths(root, include_ignored: false)
18
18
  output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
19
  return [] unless status.success?
20
20
 
21
- output.lines.map(&:chomp).reject(&:empty?)
21
+ paths = output.lines.map(&:chomp).reject(&:empty?)
22
+ return paths unless include_ignored
23
+
24
+ ignored_output, ignored_status = Open3.capture2("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
25
+ return paths unless ignored_status.success?
26
+
27
+ paths + ignored_output.lines.map(&:chomp).reject(&:empty?)
22
28
  rescue StandardError
23
29
  []
24
30
  end
25
31
 
32
+ def git_repository?(root)
33
+ _output, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: root)
34
+ status.success?
35
+ rescue StandardError
36
+ false
37
+ end
38
+
26
39
  def scanned_paths(root)
27
40
  root_path = Pathname.new(root)
28
41
  paths = []
@@ -257,12 +257,12 @@ module Kward
257
257
  value = submitted_input
258
258
  add_history(composer_input)
259
259
  if !@busy && value.strip.empty?
260
- @output_io.flush
260
+ flush_output_locked
261
261
  return value
262
262
  end
263
263
 
264
264
  clear_submitted_input_locked
265
- @output_io.flush
265
+ flush_output_locked
266
266
  value
267
267
  end
268
268
 
@@ -306,7 +306,7 @@ module Kward
306
306
 
307
307
  def exit_input
308
308
  clear_finished_input_locked
309
- @output_io.flush
309
+ flush_output_locked
310
310
  EXIT_INPUT
311
311
  end
312
312
 
@@ -8,7 +8,7 @@ module Kward
8
8
 
9
9
  def composer_layout(width, height = screen_height)
10
10
  return interactive_layout(width, height) if interactive_active_locked?
11
- return editor_layout(width, height) if editor_active?
11
+ return editor_layout(width, height) if editor_visible?
12
12
  return compact_composer_layout(width) if height < 4
13
13
  return question_composer_layout(width, height) if @question_state
14
14
 
@@ -265,6 +265,33 @@ module Kward
265
265
  @composer.cursor_logical_position
266
266
  end
267
267
 
268
+ def image_viewer_preview_rows(height, width: screen_width)
269
+ available_rows = max_project_browser_rows(height, width: width)
270
+ desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
271
+ [available_rows, desired_rows].min
272
+ end
273
+
274
+ def image_viewer_rows(width, height: screen_height)
275
+ content_width = [width - 4, 1].max
276
+ preview_rows = image_viewer_preview_rows(height, width: width)
277
+ title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
+ rows = [
279
+ colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
+ image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
+ ]
282
+ rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
283
+ zoom = (image_viewer_zoom * 100).round
284
+ rows << footer_row(content_width, "Read-only image preview · #{zoom}% · +/- zoom · Esc/Q close")
285
+ rows << bottom_border(width)
286
+ rows
287
+ end
288
+
289
+ def image_viewer_graphic_row(width, sequence)
290
+ left_border = "#{colored("│", :primary_green)} "
291
+ right_border = "#{TerminalSequences.move_to_column(width)}#{colored("│", :primary_green)}"
292
+ "#{TTY::Cursor.clear_line}#{left_border}#{sequence}#{right_border}"
293
+ end
294
+
268
295
  end
269
296
  end
270
297
  end
@@ -83,9 +83,12 @@ module Kward
83
83
 
84
84
  def editor_insert_tab
85
85
  if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
86
+ reset_editor_word_completion
86
87
  return @editor_state.replace_selections(editor_indent_unit)
87
88
  end
88
89
 
90
+ return true if editor_complete_buffer_word
91
+
89
92
  if current_editor_auto_indent? && editor_cursor_in_leading_indent?
90
93
  return editor_smart_tab_forward
91
94
  end
@@ -13,6 +13,10 @@ module Kward
13
13
  !@editor_state.nil?
14
14
  end
15
15
 
16
+ def editor_visible?
17
+ editor_active?
18
+ end
19
+
16
20
  def open_selected_file_in_editor(fallback_to_typed_path: false)
17
21
  path = selected_file_open_path
18
22
  if path
@@ -58,6 +62,7 @@ module Kward
58
62
  language: language
59
63
  )
60
64
  @editor_state.status = scratchpad_status_text(language)
65
+ @editor_agent_suspended = false
61
66
  @prompt_label = "Edit>"
62
67
  self.composer_input = ""
63
68
  self.composer_cursor = 0
@@ -95,6 +100,7 @@ module Kward
95
100
  end
96
101
 
97
102
  @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)
103
+ @editor_agent_suspended = false
98
104
  @prompt_label = "Edit>"
99
105
  self.composer_input = ""
100
106
  self.composer_cursor = 0
@@ -115,6 +121,7 @@ module Kward
115
121
  diff_view_mode = current_diff_view_mode
116
122
  viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
117
123
  @editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
124
+ @editor_agent_suspended = false
118
125
  @prompt_label = "Diff>"
119
126
  self.composer_input = ""
120
127
  self.composer_cursor = 0
@@ -243,6 +250,7 @@ module Kward
243
250
  @editor_save_as_buffer = ""
244
251
  @editor_save_callback = nil
245
252
  @editor_state = nil
253
+ @editor_agent_suspended = false
246
254
  @prompt_label = "You>"
247
255
  self.composer_input = ""
248
256
  self.composer_cursor = 0
@@ -252,6 +260,9 @@ module Kward
252
260
 
253
261
  def handle_editor_key(key)
254
262
  return if key.nil?
263
+ return handle_editor_prompt_key(key) if @editor_prompt_active
264
+
265
+ reset_editor_word_completion unless editor_word_completion_tab_key?(key) && !editor_search_active?
255
266
  mouse_result = handle_editor_mouse_key(key)
256
267
  return mouse_result unless mouse_result == false
257
268
  return handle_editor_save_as_key(key) if @editor_save_as_active
@@ -312,6 +323,64 @@ module Kward
312
323
  end
313
324
  end
314
325
 
326
+ def editor_prompt_action(instruction)
327
+ {
328
+ editor_prompt: {
329
+ instruction: instruction.to_s.strip,
330
+ display_input: ":prompt #{instruction.to_s.strip}"
331
+ }
332
+ }
333
+ end
334
+
335
+ def begin_editor_prompt
336
+ @editor_prompt_active = true
337
+ @editor_prompt_input = ""
338
+ @editor_state.status = "Prompt: "
339
+ true
340
+ end
341
+
342
+ def handle_editor_prompt_key(key)
343
+ sequence = parse_csi_u_key(key)
344
+ if sequence
345
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
346
+ return handle_editor_prompt_key("\r") if sequence[:code] == 13
347
+ return handle_editor_prompt_key("\e") if sequence[:code] == 27
348
+ return handle_editor_prompt_key("\b") if [8, 127].include?(sequence[:code])
349
+
350
+ text = csi_u_printable_text(sequence)
351
+ return handle_editor_prompt_key(text) if text
352
+
353
+ return true
354
+ end
355
+
356
+ case key
357
+ when "\e", TerminalKeys::CTRL_C, :escape
358
+ @editor_prompt_active = false
359
+ @editor_prompt_input = ""
360
+ @editor_state.status = EditorStatusText.default(readonly: @editor_state.readonly?, editor_mode: @editor_state.editor_mode)
361
+ true
362
+ when "\b", "\x7F"
363
+ @editor_prompt_input = @editor_prompt_input[0...-1]
364
+ @editor_state.status = "Prompt: #{@editor_prompt_input}"
365
+ when "\n", "\r"
366
+ instruction = @editor_prompt_input.strip
367
+ @editor_prompt_active = false
368
+ @editor_prompt_input = ""
369
+ if instruction.empty?
370
+ @editor_state.status = "Prompt instruction required"
371
+ true
372
+ else
373
+ editor_prompt_action(instruction)
374
+ end
375
+ else
376
+ if printable_key?(key)
377
+ @editor_prompt_input += key
378
+ @editor_state.status = "Prompt: #{@editor_prompt_input}"
379
+ end
380
+ true
381
+ end
382
+ end
383
+
315
384
  def handle_editor_csi_u_key(key)
316
385
  sequence = parse_csi_u_key(key)
317
386
  return false unless sequence
@@ -992,8 +1061,8 @@ module Kward
992
1061
  return false if text.empty?
993
1062
 
994
1063
  @editor_state.push_kill(text)
995
- @output_io.print(TerminalSequences.osc52(text))
996
- @output_io.flush if @output_io.respond_to?(:flush)
1064
+ print_output_locked(TerminalSequences.osc52(text))
1065
+ flush_output_locked if @output_io.respond_to?(:flush)
997
1066
  @editor_state.clear_selection
998
1067
  @editor_state.status = "Copied selection"
999
1068
  true
@@ -1103,16 +1172,16 @@ module Kward
1103
1172
  def enable_editor_mouse_reporting
1104
1173
  return if @editor_mouse_reporting_enabled
1105
1174
 
1106
- @output_io.print(TerminalSequences::MOUSE_REPORTING_ENABLE)
1107
- @output_io.flush if @output_io.respond_to?(:flush)
1175
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_ENABLE)
1176
+ flush_output_locked if @output_io.respond_to?(:flush)
1108
1177
  @editor_mouse_reporting_enabled = true
1109
1178
  end
1110
1179
 
1111
1180
  def disable_editor_mouse_reporting(force: false)
1112
1181
  return unless force || @editor_mouse_reporting_enabled
1113
1182
 
1114
- @output_io.print(TerminalSequences::MOUSE_REPORTING_DISABLE)
1115
- @output_io.flush if @output_io.respond_to?(:flush)
1183
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_DISABLE)
1184
+ flush_output_locked if @output_io.respond_to?(:flush)
1116
1185
  @editor_mouse_reporting_enabled = false
1117
1186
  end
1118
1187
 
@@ -240,8 +240,8 @@ module Kward
240
240
  end
241
241
 
242
242
  @editor_state.copy_for_kill_ring(range[0], range[1])
243
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
244
- @output_io.flush if @output_io.respond_to?(:flush)
243
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
244
+ flush_output_locked if @output_io.respond_to?(:flush)
245
245
  @editor_state.clear_selection
246
246
  @editor_state.status = "Copied region"
247
247
  true
@@ -198,6 +198,8 @@ module Kward
198
198
 
199
199
  def handle_modern_key_binding(key)
200
200
  case key
201
+ when TerminalKeys::CTRL_PERIOD
202
+ begin_editor_prompt
201
203
  when TerminalKeys::CTRL_SPACE
202
204
  true
203
205
  when TerminalKeys::CTRL_C
@@ -221,6 +223,8 @@ module Kward
221
223
  end
222
224
 
223
225
  case normalized_code
226
+ when 46
227
+ begin_editor_prompt
224
228
  when 13
225
229
  return false if editor_search_active?
226
230
 
@@ -192,6 +192,7 @@ module Kward
192
192
  end
193
193
 
194
194
  def handle_vibe_command_key(key)
195
+ result = nil
195
196
  case key
196
197
  when "\e", TerminalKeys::CTRL_C, :escape
197
198
  @editor_state.vibe_command = ""
@@ -202,20 +203,21 @@ module Kward
202
203
  when "\t"
203
204
  vibe_complete_command_path
204
205
  when "\n", "\r"
205
- execute_vibe_command(@editor_state.vibe_command)
206
+ result = execute_vibe_command(@editor_state.vibe_command)
206
207
  else
207
208
  if printable_key?(key)
208
209
  @editor_state.vibe_command = @editor_state.vibe_command.to_s + key
209
210
  @editor_state.status = ":#{@editor_state.vibe_command}"
210
211
  end
211
212
  end
212
- true
213
+ result || true
213
214
  end
214
215
 
215
216
  def execute_vibe_command(command)
216
217
  command = command.to_s.strip
217
218
  @editor_state.vibe_mode = "normal"
218
219
  @editor_state.vibe_command = ""
220
+ action = nil
219
221
  case command
220
222
  when "w"
221
223
  save_editor
@@ -225,6 +227,10 @@ module Kward
225
227
  vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
226
228
  when "run"
227
229
  vibe_record_undo { run_editor_buffer }
230
+ when /\Aprompt\s+(.+)\z/
231
+ action = editor_prompt_action(Regexp.last_match(1))
232
+ when "prompt"
233
+ @editor_state.status = "Prompt instruction required"
228
234
  when "q"
229
235
  vibe_quit_editor
230
236
  when "q!"
@@ -244,7 +250,7 @@ module Kward
244
250
  else
245
251
  @editor_state.status = "Unknown command: #{command}"
246
252
  end
247
- true
253
+ action || true
248
254
  end
249
255
 
250
256
  def vibe_edit_file(path, force: false)
@@ -426,7 +432,7 @@ module Kward
426
432
  def vibe_return_to_normal
427
433
  vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
428
434
  @editor_state.vibe_mode = "normal"
429
- @editor_state.status = "NORMAL · i insert · :w save · :q quit"
435
+ @editor_state.status = "NORMAL · i insert · :prompt ask agent · :w save · :q quit"
430
436
  true
431
437
  end
432
438
 
@@ -2239,8 +2245,8 @@ module Kward
2239
2245
  def vibe_copy_range(start_index, end_index, status, linewise: false)
2240
2246
  @editor_state.copy_range(start_index, end_index)
2241
2247
  @editor_state.vibe_kill_linewise = linewise
2242
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
2243
- @output_io.flush if @output_io.respond_to?(:flush)
2248
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
2249
+ flush_output_locked if @output_io.respond_to?(:flush)
2244
2250
  @editor_state.status = status
2245
2251
  end
2246
2252
 
@@ -264,6 +264,7 @@ module Kward
264
264
 
265
265
  def editor_status_text
266
266
  text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
267
+ text = "#{text} · #{spinner_frame}" if @editor_agent_suspended && @busy
267
268
  visible_truncate(text, [screen_width - 4, 1].max)
268
269
  end
269
270
  end
@@ -830,6 +830,17 @@ module Kward
830
830
  @cursor = @buffer.length
831
831
  end
832
832
 
833
+ def replace_buffer(content)
834
+ content = content.to_s
835
+ return false if content == @buffer
836
+
837
+ push_undo
838
+ self.buffer = content
839
+ @cursor = [@cursor, @buffer.length].min
840
+ changed!
841
+ true
842
+ end
843
+
833
844
  def replace_range(start_index, end_index, text)
834
845
  start_index, = @text_buffer.replace_range(start_index, end_index, text)
835
846
  @buffer = @text_buffer.text
@@ -0,0 +1,124 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Current-buffer word completion for the built-in composer file editor.
6
+ module EditorWordCompletion
7
+ EDITOR_COMPLETION_WORD_PATTERN = /[[:alnum:]_]+/.freeze
8
+ EDITOR_COMPLETION_PREFIX_PATTERN = /[[:alnum:]_]+\z/.freeze
9
+ EDITOR_COMPLETION_SUFFIX_PATTERN = /\A[[:alnum:]_]*/.freeze
10
+
11
+ private
12
+
13
+ def editor_complete_buffer_word
14
+ if (cycle = matching_editor_completion_cycle)
15
+ return cycle_editor_word_completion(cycle) if cycle[:candidates].length > 1
16
+
17
+ reset_editor_word_completion
18
+ end
19
+
20
+ context = editor_word_completion_context
21
+ return false unless context
22
+
23
+ candidates = editor_word_completion_candidates(context)
24
+ return false if candidates.empty?
25
+
26
+ @editor_word_completion = context.merge(
27
+ editor_state: @editor_state,
28
+ candidates: candidates,
29
+ index: 0,
30
+ previous_status: @editor_state.status
31
+ )
32
+ apply_editor_word_completion(@editor_word_completion)
33
+ true
34
+ end
35
+
36
+ def editor_word_completion_context
37
+ buffer = @editor_state.buffer
38
+ cursor = @editor_state.cursor
39
+ prefix_match = buffer[0...cursor].to_s.match(EDITOR_COMPLETION_PREFIX_PATTERN)
40
+ return nil unless prefix_match
41
+
42
+ suffix = buffer[cursor..].to_s.match(EDITOR_COMPLETION_SUFFIX_PATTERN).to_s
43
+ {
44
+ prefix: prefix_match[0],
45
+ range_start: prefix_match.begin(0),
46
+ range_end: cursor + suffix.length
47
+ }
48
+ end
49
+
50
+ def editor_word_completion_candidates(context)
51
+ matches = []
52
+ @editor_state.buffer.to_enum(:scan, EDITOR_COMPLETION_WORD_PATTERN).each do
53
+ match = Regexp.last_match
54
+ next if editor_completion_match_overlaps_context?(match, context)
55
+
56
+ word = match[0]
57
+ next unless word.length > context[:prefix].length
58
+ next unless word.start_with?(context[:prefix])
59
+
60
+ matches << [word, match.begin(0)]
61
+ end
62
+
63
+ matches
64
+ .sort_by { |_word, offset| editor_completion_candidate_order(offset, context) }
65
+ .map(&:first)
66
+ .uniq
67
+ end
68
+
69
+ def editor_completion_match_overlaps_context?(match, context)
70
+ match.begin(0) < context[:range_end] && match.end(0) > context[:range_start]
71
+ end
72
+
73
+ def editor_completion_candidate_order(offset, context)
74
+ if offset < context[:range_start]
75
+ [0, context[:range_start] - offset]
76
+ else
77
+ [1, offset - context[:range_end]]
78
+ end
79
+ end
80
+
81
+ def matching_editor_completion_cycle
82
+ cycle = @editor_word_completion
83
+ return nil unless cycle
84
+
85
+ same_editor = cycle[:editor_state].equal?(@editor_state)
86
+ same_buffer = cycle[:buffer] == @editor_state.buffer
87
+ same_cursor = cycle[:cursor] == @editor_state.cursor
88
+ return cycle if same_editor && same_buffer && same_cursor
89
+
90
+ reset_editor_word_completion
91
+ nil
92
+ end
93
+
94
+ def cycle_editor_word_completion(cycle)
95
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
96
+ apply_editor_word_completion(cycle)
97
+ true
98
+ end
99
+
100
+ def apply_editor_word_completion(cycle)
101
+ candidate = cycle[:candidates][cycle[:index]]
102
+ @editor_state.replace_range(cycle[:range_start], cycle[:range_end], candidate)
103
+ @editor_state.cursor = cycle[:range_start] + candidate.length
104
+ cycle[:range_end] = @editor_state.cursor
105
+ cycle[:buffer] = @editor_state.buffer
106
+ cycle[:cursor] = @editor_state.cursor
107
+ cycle[:status] = "Completion #{cycle[:index] + 1}/#{cycle[:candidates].length}: #{candidate}"
108
+ @editor_state.status = cycle[:status]
109
+ end
110
+
111
+ def reset_editor_word_completion
112
+ cycle = @editor_word_completion
113
+ if cycle && cycle[:editor_state].status == cycle[:status]
114
+ cycle[:editor_state].status = cycle[:previous_status]
115
+ end
116
+ @editor_word_completion = nil
117
+ end
118
+
119
+ def editor_word_completion_tab_key?(key)
120
+ !editor_tab_sequence_for(key).nil?
121
+ end
122
+ end
123
+ end
124
+ end
@@ -114,8 +114,12 @@ module Kward
114
114
  end
115
115
  end
116
116
 
117
- def project_file_paths
118
- @file_mention_paths ||= discover_project_file_paths
117
+ def project_file_paths(include_ignored: false)
118
+ if include_ignored
119
+ @project_browser_file_paths ||= discover_project_file_paths(include_ignored: true)
120
+ else
121
+ @file_mention_paths ||= discover_project_file_paths
122
+ end
119
123
  end
120
124
 
121
125
  def project_file_path_entries
@@ -126,14 +130,24 @@ module Kward
126
130
  @file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
127
131
  end
128
132
 
129
- def discover_project_file_paths
130
- paths = git_project_file_paths
131
- paths = scanned_project_file_paths if paths.empty?
133
+ def discover_project_file_paths(include_ignored: false)
134
+ paths = if include_ignored
135
+ git_project_file_paths(include_ignored: true)
136
+ else
137
+ git_project_file_paths
138
+ end
139
+ paths = scanned_project_file_paths if paths.empty? && !git_project_repository?
132
140
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
133
141
  end
134
142
 
135
- def git_project_file_paths
136
- ProjectFiles.git_paths(prompt_workspace_root)
143
+ def git_project_file_paths(include_ignored: false)
144
+ return ProjectFiles.git_paths(prompt_workspace_root) unless include_ignored
145
+
146
+ ProjectFiles.git_paths(prompt_workspace_root, include_ignored: true)
147
+ end
148
+
149
+ def git_project_repository?
150
+ ProjectFiles.git_repository?(prompt_workspace_root)
137
151
  end
138
152
 
139
153
  def scanned_project_file_paths
@@ -262,7 +262,7 @@ module Kward
262
262
  self.composer_cursor = 0
263
263
  @asking = true
264
264
  render_prompt_locked
265
- @output_io.flush
265
+ flush_output_locked
266
266
  end
267
267
  end
268
268
 
@@ -20,6 +20,14 @@ module Kward
20
20
  end
21
21
 
22
22
  def handle_editor_input_key(key)
23
+ if @editor_agent_suspended
24
+ return CANCEL_INPUT if @busy && key == TerminalKeys::CTRL_C
25
+
26
+ return true
27
+ end
28
+
29
+ reset_editor_word_completion unless editor_word_completion_tab_key?(key)
30
+
23
31
  tab_result = handle_tab_key_binding(key)
24
32
  return tab_result unless tab_result == false
25
33
 
@@ -27,9 +35,31 @@ module Kward
27
35
  result.is_a?(String) ? true : result
28
36
  end
29
37
 
38
+ def handle_image_viewer_key(key)
39
+ return true if handle_bundled_key(key) { |token| handle_image_viewer_key(token) }
40
+
41
+ sequence = parse_csi_u_key(key)
42
+ if sequence
43
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
44
+ return close_image_viewer if sequence[:code] == 27
45
+
46
+ text = csi_u_printable_text(sequence)
47
+ return zoom_image_viewer(:in) if text == "+"
48
+ return zoom_image_viewer(:out) if text == "-"
49
+ return true
50
+ end
51
+
52
+ return zoom_image_viewer(:in) if key == "+"
53
+ return zoom_image_viewer(:out) if key == "-"
54
+
55
+ close_image_viewer if key == "\e" || key == "q" || key == "Q"
56
+ true
57
+ end
58
+
30
59
  def handle_key(key)
31
60
  return submit_input if key.nil?
32
61
  return handle_interactive_key(key) if interactive_active_locked?
62
+ return handle_image_viewer_key(key) if image_viewer_active?
33
63
  return handle_editor_input_key(key) if editor_active?
34
64
  return handle_project_browser_key(key) if project_browser_visible?
35
65
  return handle_history_search_key(key) if history_search_active?
@@ -10,6 +10,7 @@ module Kward
10
10
  return question_overlay_rows(width) if @question_state
11
11
  return selection_overlay_rows(width, height: height) if @select_state
12
12
  return git_overlay_rows(width, height: height) if @git_state
13
+ return image_viewer_rows(width, height: height) if image_viewer_active?
13
14
  return project_browser_rows(width, height: height) if project_browser_visible?
14
15
  return completion_overlay_rows(width, height: height) if @completion_overlay
15
16
  return history_search_overlay_rows(width, height: height) if history_search_active?