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
@@ -10,6 +10,8 @@ module Kward
10
10
  pending = @pending_keys.shift unless @pending_keys.empty?
11
11
  return pending if pending
12
12
 
13
+ return nil if nonblock && @input_io.respond_to?(:wait_readable) && !@input_io.wait_readable(0)
14
+
13
15
  @reader.read_keypress(echo: false, raw: true, nonblock: nonblock)
14
16
  rescue TTY::Reader::InputInterrupt
15
17
  "\x03"
@@ -17,28 +19,43 @@ module Kward
17
19
  nil
18
20
  end
19
21
 
22
+ def handle_editor_input_key(key)
23
+ tab_result = handle_tab_key_binding(key)
24
+ return tab_result unless tab_result == false
25
+
26
+ result = handle_editor_key(key)
27
+ result.is_a?(String) ? true : result
28
+ end
29
+
20
30
  def handle_key(key)
21
31
  return submit_input if key.nil?
32
+ return handle_interactive_key(key) if interactive_active_locked?
33
+ return handle_editor_input_key(key) if editor_active?
34
+ return handle_project_browser_key(key) if project_browser_visible?
35
+ return handle_history_search_key(key) if history_search_active?
36
+ return true if handle_mouse_reporting_key(key)
22
37
  return if handle_bracketed_paste_key(key)
23
38
 
24
39
  csi_result = handle_csi_u_key(key)
25
40
  return csi_result unless csi_result == false
26
41
  return if handle_shift_enter_key(key)
27
- if key.is_a?(String) && key.length > 1
28
- token = next_key_token(key)
29
- if token.length < key.length
30
- queue_pending_keys(key[token.length..])
31
- return handle_key(token)
32
- end
33
- end
42
+ return true if handle_bundled_key(key) { |token| handle_key(token) }
43
+
44
+ completion_result = handle_completion_provider_key(key)
45
+ return completion_result unless completion_result == false
46
+
47
+ reasoning_result = handle_reasoning_key_binding(key)
48
+ return reasoning_result unless reasoning_result == false
49
+
50
+ tab_result = handle_tab_key_binding(key)
51
+ return tab_result unless tab_result == false
34
52
 
35
53
  binding_result = handle_composer_key_binding(key)
36
54
  return binding_result unless binding_result == false
37
55
 
38
- key_name = @reader.console.keys[key]
39
- case key_name
56
+ case key_name_for(key)
40
57
  when :return, :enter
41
- submit_input
58
+ file_open_overlay_visible? ? open_selected_file_in_editor(fallback_to_typed_path: true) : submit_input
42
59
  when :backspace
43
60
  delete_before_cursor
44
61
  when :delete
@@ -65,6 +82,8 @@ module Kward
65
82
  yank_kill_buffer
66
83
  when :ctrl_l
67
84
  redraw_screen_locked
85
+ when :ctrl_r
86
+ start_history_search
68
87
  when :left
69
88
  move_cursor_left
70
89
  when :right
@@ -74,21 +93,35 @@ module Kward
74
93
  when :end
75
94
  move_to_end_of_line
76
95
  when :up
77
- slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
96
+ if file_overlay_visible?
97
+ select_previous_file_mention
98
+ elsif slash_overlay_visible?
99
+ select_previous_slash_command
100
+ else
101
+ recall_previous_history
102
+ end
78
103
  when :down
79
- slash_overlay_visible? ? select_next_slash_command : recall_next_history
104
+ if file_overlay_visible?
105
+ select_next_file_mention
106
+ elsif slash_overlay_visible?
107
+ select_next_slash_command
108
+ else
109
+ recall_next_history
110
+ end
80
111
  else
81
112
  case key
82
113
  when "\n", "\r"
83
- submit_input
114
+ file_open_overlay_visible? ? open_selected_file_in_editor(fallback_to_typed_path: true) : submit_input
84
115
  when "\t"
85
- complete_selected_slash_command || insert_key(key)
116
+ handle_tab_completion_key
86
117
  when "\b", "\x7F"
87
118
  delete_before_cursor
88
119
  when "\x04"
89
120
  delete_at_cursor_or_exit
90
121
  when "\x03"
91
122
  cancel_input_or_interrupt
123
+ when "\x12"
124
+ start_history_search
92
125
  when "\e"
93
126
  handle_escape_sequence
94
127
  else
@@ -97,31 +130,114 @@ module Kward
97
130
  end
98
131
  end
99
132
 
133
+ def handle_history_search_key(key)
134
+ csi_result = handle_history_search_csi_u_key(key)
135
+ return csi_result unless csi_result == false
136
+ return true if handle_bundled_key(key) { |token| handle_history_search_key(token) }
137
+
138
+ case key_name_for(key)
139
+ when :return, :enter
140
+ accept_history_search
141
+ when :up
142
+ select_previous_history_search_match
143
+ when :down
144
+ select_next_history_search_match
145
+ when :backspace
146
+ update_history_search_query(composer_input[0...-1].to_s)
147
+ when :ctrl_c
148
+ cancel_history_search
149
+ else
150
+ case key
151
+ when "\n", "\r"
152
+ accept_history_search
153
+ when "\b", "\x7F"
154
+ update_history_search_query(composer_input[0...-1].to_s)
155
+ when "\x03", "\e"
156
+ cancel_history_search
157
+ else
158
+ append_history_search_key(key)
159
+ end
160
+ end
161
+ true
162
+ end
163
+
164
+ def handle_history_search_csi_u_key(key)
165
+ sequence = parse_csi_u_key(key)
166
+ return false unless sequence
167
+
168
+ code = sequence[:code]
169
+ modifier = sequence[:modifier]
170
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
171
+
172
+ if ctrl_modifier?(modifier) && ctrl_code_for(code) == 99
173
+ cancel_history_search
174
+ elsif code == 13
175
+ accept_history_search
176
+ elsif code == 27
177
+ cancel_history_search
178
+ elsif code == 8 || code == 127
179
+ update_history_search_query(composer_input[0...-1].to_s)
180
+ else
181
+ text = csi_u_printable_text(sequence)
182
+ update_history_search_query(composer_input + text) if text
183
+ end
184
+ true
185
+ end
186
+
187
+ def append_history_search_key(key)
188
+ return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
189
+
190
+ update_history_search_query(composer_input + key)
191
+ end
192
+
100
193
  def cancel_input_or_interrupt
101
194
  return CANCEL_INPUT if @busy
102
195
 
103
196
  raise Interrupt
104
197
  end
105
198
 
199
+ def handle_tab_completion_key
200
+ open_selected_file_in_editor || complete_selected_file_mention || complete_selected_slash_command || insert_key("\t")
201
+ end
202
+
106
203
  def handle_escape_sequence
107
204
  pending_sequence = read_pending_escape_sequence
108
- return true if pending_sequence.empty? && dismiss_slash_overlay
205
+ return true if pending_sequence.empty? && (dismiss_file_overlay || dismiss_slash_overlay)
109
206
 
110
207
  full_sequence = "\e#{pending_sequence}"
208
+ return true if handle_mouse_reporting_key(full_sequence)
209
+
111
210
  sequence = next_key_token(full_sequence)
112
211
  queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
113
- return true if sequence == "\e" && dismiss_slash_overlay
212
+ return true if sequence == "\e" && (dismiss_file_overlay || dismiss_slash_overlay)
114
213
  return true if handle_shift_enter_key(sequence)
115
214
 
215
+ reasoning_result = handle_reasoning_key_binding(sequence)
216
+ return reasoning_result unless reasoning_result == false
217
+
218
+ tab_result = handle_tab_key_binding(sequence)
219
+ return tab_result unless tab_result == false
220
+
116
221
  binding_result = handle_composer_key_binding(sequence)
117
222
  return binding_result unless binding_result == false
118
223
 
119
- key_name = @reader.console.keys[sequence]
120
- case key_name
224
+ case key_name_for(sequence)
121
225
  when :up
122
- slash_overlay_visible? ? select_previous_slash_command : recall_previous_history
226
+ if file_overlay_visible?
227
+ select_previous_file_mention
228
+ elsif slash_overlay_visible?
229
+ select_previous_slash_command
230
+ else
231
+ recall_previous_history
232
+ end
123
233
  when :down
124
- slash_overlay_visible? ? select_next_slash_command : recall_next_history
234
+ if file_overlay_visible?
235
+ select_next_file_mention
236
+ elsif slash_overlay_visible?
237
+ select_next_slash_command
238
+ else
239
+ recall_next_history
240
+ end
125
241
  when :left
126
242
  move_cursor_left
127
243
  when :right
@@ -145,6 +261,15 @@ module Kward
145
261
  true
146
262
  end
147
263
 
264
+ def handle_mouse_reporting_key(key)
265
+ text = key.to_s
266
+ match = text.match(/\A(?:\e)?\[<\d+;\d+;\d+[Mm]/)
267
+ return false unless match
268
+
269
+ queue_pending_keys(text[match[0].length..]) if match[0].length < text.length
270
+ true
271
+ end
272
+
148
273
  def read_bracketed_paste(key)
149
274
  text = key.to_s
150
275
  return nil unless text.start_with?(BRACKETED_PASTE_START)
@@ -174,39 +299,93 @@ module Kward
174
299
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
175
300
 
176
301
  case code
302
+ when 9
303
+ if ctrl_modifier?(modifier)
304
+ shift_modifier?(modifier) ? { tab_action: :previous } : { tab_action: :next }
305
+ elsif shift_modifier?(modifier)
306
+ handle_reasoning_key_binding(key) || handle_tab_completion_key
307
+ else
308
+ completion_result = handle_completion_provider_key("\t")
309
+ completion_result == false ? handle_reasoning_key_binding("\t") || handle_tab_completion_key : completion_result
310
+ end
177
311
  when 13
178
- modifier == 2 ? insert_string("\n") : submit_input
312
+ if modifier == 2
313
+ insert_string("\n")
314
+ elsif file_open_overlay_visible?
315
+ open_selected_file_in_editor(fallback_to_typed_path: true)
316
+ else
317
+ submit_input
318
+ end
179
319
  when 27
180
- dismiss_slash_overlay || false
320
+ dismiss_file_overlay || dismiss_slash_overlay || false
181
321
  when 8, 127
182
322
  alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
183
323
  nil
184
324
  when 4
185
325
  delete_at_cursor_or_exit
186
326
  else
187
- handle_modified_csi_u_key(code, modifier)
327
+ handle_modified_csi_u_key(code, modifier) || insert_csi_u_text(sequence)
188
328
  end
189
329
  end
190
330
 
191
331
  def parse_csi_u_key(key)
192
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
332
+ match = key.to_s.match(/\A\e\[(\d+)((?:;[\d:]*)*)u/)
193
333
  return nil unless match
194
334
 
195
- modifiers = match[2].to_s
335
+ fields = match[2].to_s.split(";", -1)[1..] || []
336
+ modifiers = fields[0].to_s
196
337
  modifier = (modifiers.empty? ? "1" : modifiers).split(":", 2).first.to_i
197
338
  {
198
339
  sequence: match[0],
199
340
  code: match[1].to_i,
200
341
  modifiers: modifiers,
201
342
  modifier: modifier,
343
+ text: fields[1].to_s,
202
344
  remaining: key.to_s[match[0].length..]
203
345
  }
204
346
  end
205
347
 
348
+ def insert_csi_u_text(sequence)
349
+ text = csi_u_printable_text(sequence)
350
+ return true if text.nil? && csi_u_text_field?(sequence)
351
+ return false unless text
352
+
353
+ insert_string(text)
354
+ end
355
+
356
+ def csi_u_text_field?(sequence)
357
+ !sequence[:text].to_s.empty?
358
+ end
359
+
360
+ def csi_u_printable_text(sequence)
361
+ text = csi_u_text(sequence)
362
+ return text unless text.empty?
363
+ return nil if csi_u_text_field?(sequence)
364
+ return nil if ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])
365
+ return nil unless sequence[:code].between?(32, 126)
366
+
367
+ sequence[:code].chr(Encoding::UTF_8)
368
+ end
369
+
370
+ def csi_u_text(sequence)
371
+ sequence[:text].to_s.split(":").map do |codepoint|
372
+ character = csi_u_codepoint_character(codepoint)
373
+ return "" unless character
374
+
375
+ character
376
+ end.join
377
+ end
378
+
379
+ def csi_u_codepoint_character(codepoint)
380
+ codepoint.to_i.chr(Encoding::UTF_8)
381
+ rescue RangeError
382
+ nil
383
+ end
384
+
206
385
  def handle_modified_csi_u_key(code, modifier)
207
386
  return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
208
387
 
209
- normalized_code = code.to_i.chr.downcase.ord rescue code
388
+ normalized_code = ctrl_code_for(code)
210
389
  if ctrl_modifier?(modifier)
211
390
  case normalized_code
212
391
  when 97
@@ -227,6 +406,8 @@ module Kward
227
406
  kill_line_after_cursor
228
407
  when 108
229
408
  redraw_screen_locked
409
+ when 114
410
+ start_history_search
230
411
  when 117
231
412
  kill_line_before_cursor
232
413
  when 119
@@ -252,6 +433,32 @@ module Kward
252
433
  end
253
434
  end
254
435
 
436
+ def ctrl_code_for(code)
437
+ code.to_i.chr.downcase.ord rescue code
438
+ end
439
+
440
+ def key_name_for(key)
441
+ cursor_key_name(key) || @reader.console.keys[key]
442
+ end
443
+
444
+ def cursor_key_name(key)
445
+ text = key.to_s
446
+ case text
447
+ when /\A\e\[[0-9;:]*A\z/, "\eOA"
448
+ :up
449
+ when /\A\e\[[0-9;:]*B\z/, "\eOB"
450
+ :down
451
+ when /\A\e\[[0-9;:]*C\z/, "\eOC"
452
+ :right
453
+ when /\A\e\[[0-9;:]*D\z/, "\eOD"
454
+ :left
455
+ when "\e[5~"
456
+ :pageup
457
+ when "\e[6~"
458
+ :pagedown
459
+ end
460
+ end
461
+
255
462
  def ctrl_modifier?(modifier)
256
463
  ((modifier.to_i - 1) & 4).positive?
257
464
  end
@@ -260,6 +467,14 @@ module Kward
260
467
  ((modifier.to_i - 1) & 2).positive?
261
468
  end
262
469
 
470
+ def super_modifier?(modifier)
471
+ ((modifier.to_i - 1) & 8).positive?
472
+ end
473
+
474
+ def shift_modifier?(modifier)
475
+ ((modifier.to_i - 1) & 1).positive?
476
+ end
477
+
263
478
  def handle_shift_enter_key(key)
264
479
  sequence = shift_enter_sequence_for(key)
265
480
  return false unless sequence
@@ -278,6 +493,17 @@ module Kward
278
493
  end
279
494
  end
280
495
 
496
+ def handle_bundled_key(key)
497
+ return false unless key.is_a?(String) && key.length > 1
498
+
499
+ token = next_key_token(key)
500
+ return false unless token.length < key.length
501
+
502
+ queue_pending_keys(key[token.length..])
503
+ yield token
504
+ true
505
+ end
506
+
281
507
  def next_key_token(keys)
282
508
  text = keys.to_s
283
509
  text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
@@ -288,7 +514,7 @@ module Kward
288
514
 
289
515
  def alt_key_sequence?(char)
290
516
  char = char.to_s
291
- char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
517
+ char.match?(/[[:alnum:]]/) || char == "\b" || char == "\x7F"
292
518
  end
293
519
 
294
520
  def shift_enter_sequence_for(key)
@@ -310,6 +536,123 @@ module Kward
310
536
  sequence
311
537
  end
312
538
 
539
+ CTRL_TAB_SEQUENCES = ["\e[9;5u", "\e[27;5;9~", "\e[1;5I"].freeze
540
+ CTRL_SHIFT_TAB_SEQUENCES = ["\e[9;6u", "\e[27;6;9~", "\e[1;6I", "\e[1;6Z"].freeze
541
+ SHIFT_TAB_SEQUENCES = ["\e[Z", "\e[1;2Z", "\e[9;2u", "\e[27;2;9~", "\e[1;2I"].freeze
542
+
543
+ def handle_completion_provider_key(key)
544
+ return false unless key == "\t" && @completion_provider
545
+
546
+ result = @completion_provider.call(composer_input, composer_cursor)
547
+ return true unless result
548
+
549
+ apply_completion_result(result)
550
+ true
551
+ end
552
+
553
+ def apply_completion_result(result)
554
+ range = result[:range] || result["range"] || result.range
555
+ replacement = result[:replacement] || result["replacement"] || result.replacement
556
+ candidates = result[:candidates] || result["candidates"] || result.candidates
557
+ original = composer_input
558
+ before = original[0...range.begin].to_s
559
+ after = original[range.end..].to_s
560
+ self.composer_input = "#{before}#{replacement}#{after}"
561
+ self.composer_cursor = before.length + replacement.to_s.length
562
+ show_completion_candidates(candidates, replacement) if candidates.to_a.length > 1 && replacement.to_s == original[range]
563
+ end
564
+
565
+ def show_completion_candidates(candidates, replacement)
566
+ lines = candidates.to_a.first(40)
567
+ text = ["completions:", *lines.map { |candidate| " #{candidate}" }].join("\n")
568
+ write_completion_transcript_locked(text)
569
+ end
570
+
571
+ def write_completion_transcript_locked(text)
572
+ with_synchronized_output_locked do
573
+ clear_prompt_for_output_locked
574
+ write_transcript_text_locked("\n#{text}\n")
575
+ render_prompt_after_output_locked
576
+ end
577
+ end
578
+
579
+ def handle_reasoning_key_binding(key)
580
+ return false if @busy || @select_state || @question_state
581
+ return false if file_overlay_visible? || slash_overlay_visible?
582
+ return false if @slash_overlay_dismissed_input && @slash_overlay_dismissed_input == composer_input
583
+ mention_token = active_file_mention_token
584
+ open_token = active_file_open_token
585
+ return false if mention_token && @file_overlay_dismissed_token == mention_token
586
+ return false if open_token && @file_open_dismissed_token == open_token
587
+
588
+ case key
589
+ when "\t"
590
+ { reasoning_action: :next }
591
+ when *SHIFT_TAB_SEQUENCES
592
+ { reasoning_action: :previous }
593
+ else
594
+ false
595
+ end
596
+ end
597
+
598
+ def handle_tab_key_binding(key)
599
+ return false if @select_state || @question_state || @tabs.empty?
600
+
601
+ navigation_result = handle_ctrl_tab_navigation_key_binding(key)
602
+ return navigation_result unless navigation_result == false
603
+
604
+ @tab_keybindings == "ctrl" ? handle_ctrl_tab_key_binding(key) : handle_alt_tab_key_binding(key)
605
+ end
606
+
607
+ def handle_ctrl_tab_navigation_key_binding(key)
608
+ case key
609
+ when *CTRL_TAB_SEQUENCES
610
+ { tab_action: :next }
611
+ when *CTRL_SHIFT_TAB_SEQUENCES
612
+ { tab_action: :previous }
613
+ else
614
+ false
615
+ end
616
+ end
617
+
618
+ def handle_ctrl_tab_key_binding(key)
619
+ case key
620
+ when "\x14", "\e[116;5u"
621
+ { tab_action: :new }
622
+ when "\e[119;5u"
623
+ { tab_action: :close }
624
+ else
625
+ ctrl_number_tab_action(key)
626
+ end
627
+ end
628
+
629
+ def ctrl_number_tab_action(key)
630
+ match = key.to_s.match(/\A\e\[((?:49)|(?:5[0-7]));5u\z/)
631
+ return false unless match
632
+
633
+ { tab_action: :select, index: match[1].to_i - 49 }
634
+ end
635
+
636
+ def handle_alt_tab_key_binding(key)
637
+ case key
638
+ when "\et", "\eT"
639
+ { tab_action: :new }
640
+ when "\e[1;3C", "\e[3C"
641
+ { tab_action: :next }
642
+ when "\e[1;3D", "\e[3D"
643
+ { tab_action: :previous }
644
+ else
645
+ alt_number_tab_action(key)
646
+ end
647
+ end
648
+
649
+ def alt_number_tab_action(key)
650
+ match = key.to_s.match(/\A\e([1-9])\z/)
651
+ return false unless match
652
+
653
+ { tab_action: :select, index: match[1].to_i - 1 }
654
+ end
655
+
313
656
  def handle_composer_key_binding(key)
314
657
  case key
315
658
  when "\x01"
@@ -355,14 +698,23 @@ module Kward
355
698
  end
356
699
  end
357
700
 
701
+ def parse_modified_ansi_key(key)
702
+ if (match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/))
703
+ { type: :cursor, modifier: match[2].to_i, final: match[3] }
704
+ elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
705
+ { type: :delete, modifier: match[1].to_i }
706
+ end
707
+ end
708
+
358
709
  def handle_modified_ansi_key(key)
359
- match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/)
360
- if match
361
- modifier = match[2].to_i
362
- final = match[3]
363
- return false unless alt_modifier?(modifier)
710
+ sequence = parse_modified_ansi_key(key)
711
+ return false unless sequence
712
+
713
+ case sequence[:type]
714
+ when :cursor
715
+ return false unless alt_modifier?(sequence[:modifier])
364
716
 
365
- case final
717
+ case sequence[:final]
366
718
  when "C"
367
719
  move_to_next_word
368
720
  when "D"
@@ -374,8 +726,8 @@ module Kward
374
726
  else
375
727
  false
376
728
  end
377
- elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
378
- alt_modifier?(match[1].to_i) ? delete_word_after_cursor : delete_at_cursor
729
+ when :delete
730
+ alt_modifier?(sequence[:modifier]) ? delete_word_after_cursor : delete_at_cursor
379
731
  else
380
732
  false
381
733
  end
@@ -9,10 +9,29 @@ module Kward
9
9
  def active_overlay_rows(width, height: screen_height)
10
10
  return question_overlay_rows(width) if @question_state
11
11
  return selection_overlay_rows(width, height: height) if @select_state
12
+ return git_overlay_rows(width, height: height) if @git_state
13
+ return project_browser_rows(width, height: height) if project_browser_visible?
14
+ return history_search_overlay_rows(width, height: height) if history_search_active?
15
+ return file_overlay_rows(width, height: height) if file_overlay_visible?
12
16
 
13
17
  slash_overlay_rows(width, height: height)
14
18
  end
15
19
 
20
+ def history_search_overlay_rows(width, height: screen_height)
21
+ matches = history_search_matches
22
+ if matches.empty?
23
+ return overlay_card_rows("History", [overlay_text_line("No matching history", :muted)], width)
24
+ end
25
+
26
+ max_rows = max_overlay_list_rows(height)
27
+ selected = @composer.history_search_index
28
+ start = centered_list_window_start(selected, matches.length, max_rows)
29
+ rows = (matches[start, max_rows] || []).each_with_index.map do |value, offset|
30
+ overlay_choice_line(value, selected: start + offset == selected)
31
+ end
32
+ overlay_card_rows("History", rows, width)
33
+ end
34
+
16
35
  def overlay_card_rows(title, content_rows, width)
17
36
  card_width = overlay_card_width(width)
18
37
  inner_width = [card_width - 4, 1].max
@@ -32,7 +51,7 @@ module Kward
32
51
  def overlay_top_border(title, card_width)
33
52
  title = visible_truncate(title.to_s, [card_width - 4, 1].max)
34
53
  plain_length = ANSI.strip(title).length
35
- colored("╭", :primary_green) + " #{colored(title, :bright_accent_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
54
+ colored("╭", :primary_green) + " #{colored(title, :primary_green, :bold)} " + colored("─" * [card_width - plain_length - 4, 0].max, :primary_green) + colored("╮", :primary_green)
36
55
  end
37
56
 
38
57
  def overlay_bottom_border(card_width)
@@ -41,7 +60,7 @@ module Kward
41
60
 
42
61
  def overlay_content_row(row, inner_width)
43
62
  text = visible_truncate(row[:text], inner_width)
44
- text = colored(text, :bright_accent_green, :bold) if row[:selected]
63
+ text = colored(text, :primary_green, :bold) if row[:selected]
45
64
  colored("│", :primary_green) + " " + visible_ljust(text, inner_width) + " " + colored("│", :primary_green)
46
65
  end
47
66