kward 0.70.0 → 0.71.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -10,9 +10,6 @@ module Kward
10
10
  return select_current_choice if key.nil?
11
11
  return if handle_select_bracketed_paste_key(key)
12
12
 
13
- csi_result = handle_select_csi_u_key(key)
14
- return csi_result unless csi_result == false
15
-
16
13
  if key.is_a?(String) && key.length > 1
17
14
  token = next_key_token(key)
18
15
  if token.length < key.length
@@ -21,22 +18,32 @@ module Kward
21
18
  end
22
19
  end
23
20
 
21
+ return handle_select_confirmation_key(key) if select_confirmation_active?
22
+
23
+ csi_result = handle_select_csi_u_key(key)
24
+ return csi_result unless csi_result == false
25
+
26
+ return handle_select_input_key(key) if select_input_active?
27
+
28
+ binding_result = handle_select_search_key_binding(key)
29
+ return binding_result unless binding_result == false
30
+
24
31
  key_name = @reader.console.keys[key]
25
32
  case key_name
26
33
  when :return, :enter
27
34
  select_current_choice
28
35
  when :backspace
29
- select_delete_before_cursor
36
+ select_delete_before_cursor if select_search_active?
30
37
  when :delete
31
- select_delete_at_cursor
38
+ select_delete_at_cursor if select_search_active?
32
39
  when :left
33
- self.composer_cursor -= 1 if composer_cursor.positive?
40
+ select_move_cursor_left if select_search_active?
34
41
  when :right
35
- self.composer_cursor += 1 if composer_cursor < composer_input.length
42
+ select_move_cursor_right if select_search_active?
36
43
  when :home
37
- self.composer_cursor = 0
44
+ self.composer_cursor = 0 if select_search_active?
38
45
  when :end
39
- self.composer_cursor = composer_input.length
46
+ self.composer_cursor = composer_input.length if select_search_active?
40
47
  when :up
41
48
  select_previous_choice
42
49
  when :down
@@ -46,69 +53,111 @@ module Kward
46
53
  when "\n", "\r"
47
54
  select_current_choice
48
55
  when "\b", "\x7F"
49
- select_delete_before_cursor
56
+ select_delete_before_cursor if select_search_active?
50
57
  when "\e"
51
58
  handle_select_escape_sequence
52
59
  else
53
- select_insert_key(key)
60
+ select_typed_key(key)
54
61
  end
55
62
  end
56
63
  end
57
64
 
65
+ def drain_pending_select_keys_locked(result)
66
+ until result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL || @pending_keys.empty?
67
+ result = handle_select_key(@pending_keys.shift)
68
+ end
69
+ result
70
+ end
71
+
58
72
  def handle_select_csi_u_key(key)
59
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
60
- return false unless match
73
+ sequence = parse_csi_u_key(key)
74
+ return false unless sequence
61
75
 
62
- sequence = match[0]
63
- code = match[1].to_i
64
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
76
+ code = sequence[:code]
77
+ modifiers = sequence[:modifiers]
78
+ modifier = sequence[:modifier]
79
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
65
80
 
66
81
  case code
67
82
  when 13
68
- select_current_choice
83
+ select_input_active? ? select_input_action_result : select_current_choice
69
84
  when 27
70
- SELECT_CANCEL
85
+ if select_input_active?
86
+ clear_select_input
87
+ elsif select_search_active?
88
+ select_cancel_search
89
+ else
90
+ SELECT_CANCEL
91
+ end
71
92
  when 8, 127
72
- select_delete_before_cursor
93
+ if select_editing_active?
94
+ alt_modifier?(modifier) ? select_delete_word_before_cursor : select_delete_before_cursor
95
+ end
96
+ nil
97
+ when 4
98
+ select_delete_at_cursor if select_editing_active?
73
99
  nil
100
+ else
101
+ modified_result = handle_select_modified_csi_u_key(code, modifier)
102
+ return modified_result unless modified_result == false
103
+
104
+ handle_select_printable_csi_u_key(code, modifiers)
105
+ end
106
+ end
107
+
108
+ def handle_select_modified_csi_u_key(code, modifier)
109
+ return false unless select_editing_active?
110
+ return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
111
+
112
+ normalized_code = code.to_i.chr.downcase.ord rescue code
113
+ if ctrl_modifier?(modifier)
114
+ handle_select_ctrl_key(normalized_code)
115
+ elsif alt_modifier?(modifier)
116
+ handle_select_alt_key(normalized_code)
74
117
  else
75
118
  false
76
119
  end
77
120
  end
78
121
 
122
+ def handle_select_printable_csi_u_key(code, modifiers)
123
+ return false unless modifiers.empty? || modifiers == "1"
124
+ return false unless code.between?(32, 126)
125
+
126
+ key = code.chr(Encoding::UTF_8)
127
+ select_typed_key(key)
128
+ end
129
+
79
130
  def handle_select_escape_sequence
80
- sequence = read_pending_escape_sequence
81
- return SELECT_CANCEL if sequence.empty? || sequence.start_with?("\e")
131
+ pending_sequence = read_pending_escape_sequence
132
+ return clear_select_input if pending_sequence.empty? && select_input_active?
133
+ return select_cancel_search if pending_sequence.empty? && select_search_active?
134
+ return SELECT_CANCEL if pending_sequence.empty?
135
+
136
+ full_sequence = "\e#{pending_sequence}"
137
+ sequence = next_key_token(full_sequence)
138
+ queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
139
+ return SELECT_CANCEL if sequence == "\e"
82
140
 
83
- key_name = @reader.console.keys["\e#{sequence}"]
141
+ key_name = @reader.console.keys[sequence]
84
142
  case key_name
85
143
  when :up
86
144
  select_previous_choice
87
145
  when :down
88
146
  select_next_choice
89
147
  when :left
90
- self.composer_cursor -= 1 if composer_cursor.positive?
148
+ select_move_cursor_left if select_editing_active?
91
149
  when :right
92
- self.composer_cursor += 1 if composer_cursor < composer_input.length
150
+ select_move_cursor_right if select_editing_active?
93
151
  end
94
152
  true
95
153
  end
96
154
 
97
155
  def handle_select_bracketed_paste_key(key)
98
- text = key.to_s
99
- return false unless text.start_with?(BRACKETED_PASTE_START)
100
-
101
- pasted = text[BRACKETED_PASTE_START.length..] || ""
102
- until pasted.include?(BRACKETED_PASTE_END)
103
- chunk = @reader.read_keypress(echo: false, raw: true)
104
- break if chunk.nil?
156
+ paste = read_bracketed_paste(key)
157
+ return false unless paste
105
158
 
106
- pasted << chunk.to_s
107
- end
108
-
109
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
110
- select_insert_string(normalize_paste(content || ""))
111
- queue_pending_keys(remaining) if remaining && !remaining.empty?
159
+ select_insert_string(normalize_paste(paste[:content])) if select_editing_active?
160
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
112
161
  true
113
162
  end
114
163
 
@@ -116,8 +165,233 @@ module Kward
116
165
  selected_selection_choice || custom_selection_choice || SELECT_CANCEL
117
166
  end
118
167
 
168
+ def handle_select_confirmation_key(key)
169
+ if key.to_s.start_with?("\e")
170
+ clear_select_confirmation
171
+ return true
172
+ end
173
+
174
+ key == @select_state[:confirm_key] ? select_action_key(key) : true
175
+ end
176
+
177
+ def handle_select_input_key(key)
178
+ key_name = @reader.console.keys[key]
179
+ case key_name
180
+ when :return, :enter
181
+ select_input_action_result
182
+ when :backspace
183
+ select_delete_before_cursor
184
+ when :delete
185
+ select_delete_at_cursor
186
+ when :left
187
+ select_move_cursor_left
188
+ when :right
189
+ select_move_cursor_right
190
+ when :home
191
+ self.composer_cursor = 0
192
+ when :end
193
+ self.composer_cursor = composer_input.length
194
+ else
195
+ case key
196
+ when "\n", "\r"
197
+ select_input_action_result
198
+ when "\b", "\x7F"
199
+ select_delete_before_cursor
200
+ when "\e"
201
+ clear_select_input
202
+ true
203
+ else
204
+ handle_select_search_key_binding(key) || select_insert_key(key)
205
+ end
206
+ end
207
+ end
208
+
209
+ def select_action_key(key)
210
+ return nil unless key.is_a?(String) && key.length == 1
211
+
212
+ action_keys = @select_state ? @select_state[:action_keys].to_h : {}
213
+ action = action_keys[key]
214
+ choice = selected_selection_choice
215
+ return nil unless action && choice
216
+
217
+ if select_confirmation_active?
218
+ return nil unless key == @select_state[:confirm_key]
219
+
220
+ clear_select_confirmation
221
+ return action.merge(choice: choice).reject { |name, _value| name == :confirm || name == :confirm_title }
222
+ end
223
+
224
+ if action[:confirm]
225
+ @select_state[:confirm_key] = key
226
+ @select_state[:confirm_text] = action[:confirm].to_s
227
+ @select_state[:confirm_title] = action[:confirm_title].to_s
228
+ return true
229
+ end
230
+
231
+ if action[:input_prompt]
232
+ @select_state[:input_action] = action
233
+ @select_state[:input_choice] = choice
234
+ @select_state[:input_prompt_label] = @prompt_label
235
+ @prompt_label = action[:input_prompt].to_s
236
+ self.composer_input = ""
237
+ self.composer_cursor = 0
238
+ return true
239
+ end
240
+
241
+ action.merge(choice: choice)
242
+ end
243
+
244
+ def select_confirmation_active?
245
+ @select_state && !@select_state[:confirm_key].to_s.empty?
246
+ end
247
+
248
+ def select_input_active?
249
+ @select_state && @select_state[:input_action]
250
+ end
251
+
252
+ def select_input_action_result
253
+ return unless @select_state
254
+
255
+ action = @select_state[:input_action].dup
256
+ choice = @select_state[:input_choice]
257
+ input = composer_input.strip
258
+ clear_select_input
259
+ action.merge(choice: choice, input: input).reject { |name, _value| name == :input_prompt }
260
+ end
261
+
262
+ def clear_select_input
263
+ return unless @select_state
264
+
265
+ @prompt_label = @select_state[:input_prompt_label].to_s unless @select_state[:input_prompt_label].to_s.empty?
266
+ @select_state.delete(:input_action)
267
+ @select_state.delete(:input_choice)
268
+ @select_state.delete(:input_prompt_label)
269
+ self.composer_input = ""
270
+ self.composer_cursor = 0
271
+ end
272
+
273
+ def clear_select_confirmation
274
+ return unless @select_state
275
+
276
+ @select_state.delete(:confirm_key)
277
+ @select_state.delete(:confirm_text)
278
+ @select_state.delete(:confirm_title)
279
+ end
280
+
281
+ def select_action_result?(result)
282
+ result.is_a?(Hash) && result.key?(:action) && result.key?(:choice)
283
+ end
284
+
285
+ def select_action_handler(result, action_handlers)
286
+ action_handlers.to_h[result[:action]] || action_handlers.to_h[result[:action].to_s]
287
+ end
288
+
289
+ def run_select_action(result, handler)
290
+ begin_select_action(result[:activity])
291
+ minimum_busy_until = result[:activity] ? monotonic_now + SELECT_ACTION_MINIMUM_BUSY_SECONDS : nil
292
+ action_result = nil
293
+ action_error = nil
294
+ worker = Thread.new do
295
+ action_result = if result.key?(:input) && handler.arity != 1
296
+ handler.call(result[:choice], result[:input])
297
+ else
298
+ handler.call(result[:choice])
299
+ end
300
+ rescue StandardError => e
301
+ action_error = e
302
+ end
303
+
304
+ while worker.alive? || (minimum_busy_until && monotonic_now < minimum_busy_until)
305
+ tick_select_action_locked
306
+ sleep 0.02
307
+ end
308
+ worker.join
309
+ raise action_error if action_error
310
+
311
+ apply_select_action_result(action_result)
312
+ ensure
313
+ finish_select_action
314
+ end
315
+
316
+ def apply_select_action_result(result)
317
+ return SELECT_CONTINUE if result == SELECT_CONTINUE
318
+ return result unless select_continue_result?(result)
319
+
320
+ @mutex.synchronize do
321
+ if @select_state
322
+ @select_state[:choices] = Array(result[:choices]).map(&:to_s) if result.key?(:choices)
323
+ @select_state[:selection_index] = result[:selection_index].to_i if result.key?(:selection_index)
324
+ end
325
+ render_prompt_locked
326
+ @output_io.flush
327
+ end
328
+ SELECT_CONTINUE
329
+ end
330
+
331
+ def select_continue_result?(result)
332
+ result.is_a?(Hash) && result[:select_continue]
333
+ end
334
+
335
+ def begin_select_action(activity)
336
+ return if activity.to_s.empty?
337
+
338
+ @mutex.synchronize do
339
+ @busy = true
340
+ @busy_activity = normalize_busy_activity(activity)
341
+ @asking = true
342
+ reset_spinner_locked
343
+ render_prompt_locked
344
+ @output_io.flush
345
+ end
346
+ end
347
+
348
+ def finish_select_action
349
+ @mutex.synchronize do
350
+ @busy = false
351
+ @busy_activity = "streaming"
352
+ @select_state&.delete(:busy_activity)
353
+ render_prompt_locked if @asking
354
+ @output_io.flush
355
+ end
356
+ end
357
+
358
+ def tick_select_action_locked
359
+ @mutex.synchronize do
360
+ resized = handle_resize_locked
361
+ spun = tick_spinner_locked
362
+ footer_refreshed = tick_footer_locked
363
+ render_prompt_locked if resized || spun || footer_refreshed
364
+ @output_io.flush if resized || spun || footer_refreshed
365
+ end
366
+ end
367
+
368
+ def normalized_select_action_keys(action_keys)
369
+ action_keys.to_h.each_with_object({}) do |(key, action), normalized|
370
+ next unless key.to_s.length == 1
371
+
372
+ normalized_action = normalized_select_action(action)
373
+ normalized[key.to_s] = normalized_action if normalized_action
374
+ end
375
+ end
376
+
377
+ def normalized_select_action(action)
378
+ if action.is_a?(Hash)
379
+ name = action[:action] || action["action"]
380
+ activity = action[:activity] || action["activity"]
381
+ confirm = action[:confirm] || action["confirm"]
382
+ confirm_title = action[:confirm_title] || action["confirm_title"]
383
+ input_prompt = action[:input_prompt] || action["input_prompt"]
384
+ defer_finish_render = action[:defer_finish_render] || action["defer_finish_render"]
385
+ else
386
+ name = action
387
+ end
388
+ return nil if name.to_s.empty?
389
+
390
+ { action: name.to_sym, activity: activity.to_s, confirm: confirm.to_s, confirm_title: confirm_title.to_s, input_prompt: input_prompt.to_s, defer_finish_render: defer_finish_render }.delete_if { |_key, value| value.to_s.empty? }
391
+ end
392
+
119
393
  def custom_selection_choice
120
- return nil unless @select_state && @select_state[:custom]
394
+ return nil unless @select_state && @select_state[:custom] && select_search_active?
121
395
 
122
396
  value = composer_input.strip
123
397
  value.empty? ? nil : value
@@ -144,6 +418,168 @@ module Kward
144
418
  @select_state[:selection_index] = next_list_selection_index(selection_index, matches.length)
145
419
  end
146
420
 
421
+ def handle_select_search_key_binding(key)
422
+ return false unless select_editing_active?
423
+
424
+ case key
425
+ when "\x01"
426
+ select_move_to_start_of_line
427
+ when "\x02"
428
+ select_move_cursor_left
429
+ when "\x04"
430
+ select_delete_at_cursor
431
+ when "\x05"
432
+ select_move_to_end_of_line
433
+ when "\x06"
434
+ select_move_cursor_right
435
+ when "\x08"
436
+ select_delete_before_cursor
437
+ when "\x0B"
438
+ select_kill_line_after_cursor
439
+ when "\x0C"
440
+ redraw_screen_locked
441
+ when "\x15"
442
+ select_kill_line_before_cursor
443
+ when "\x17"
444
+ select_delete_word_before_cursor
445
+ when "\x19"
446
+ select_yank_kill_buffer
447
+ when "\eb"
448
+ select_move_to_previous_word
449
+ when "\ed"
450
+ select_delete_word_after_cursor
451
+ when "\ef"
452
+ select_move_to_next_word
453
+ when "\e\b", "\e\x7F"
454
+ select_delete_word_before_cursor
455
+ else
456
+ false
457
+ end
458
+ end
459
+
460
+ def handle_select_ctrl_key(code)
461
+ case code
462
+ when 97
463
+ select_move_to_start_of_line
464
+ when 98
465
+ select_move_cursor_left
466
+ when 100
467
+ select_delete_at_cursor
468
+ when 101
469
+ select_move_to_end_of_line
470
+ when 102
471
+ select_move_cursor_right
472
+ when 104
473
+ select_delete_before_cursor
474
+ when 107
475
+ select_kill_line_after_cursor
476
+ when 108
477
+ redraw_screen_locked
478
+ when 117
479
+ select_kill_line_before_cursor
480
+ when 119
481
+ select_delete_word_before_cursor
482
+ when 121
483
+ select_yank_kill_buffer
484
+ else
485
+ false
486
+ end
487
+ end
488
+
489
+ def handle_select_alt_key(code)
490
+ case code
491
+ when 98
492
+ select_move_to_previous_word
493
+ when 100
494
+ select_delete_word_after_cursor
495
+ when 102
496
+ select_move_to_next_word
497
+ else
498
+ false
499
+ end
500
+ end
501
+
502
+ def select_typed_key(key)
503
+ return select_insert_key(key) if select_input_active?
504
+ return select_begin_search if key == "/" && !select_search_active?
505
+ return select_action_key(key) unless select_search_active?
506
+
507
+ select_insert_key(key)
508
+ end
509
+
510
+ def select_begin_search
511
+ return unless @select_state
512
+
513
+ @select_state[:search_active] = true
514
+ self.composer_input = ""
515
+ self.composer_cursor = 0
516
+ true
517
+ end
518
+
519
+ def select_cancel_search
520
+ return unless @select_state
521
+
522
+ @select_state[:search_active] = false
523
+ self.composer_input = ""
524
+ self.composer_cursor = 0
525
+ @select_state[:selection_index] = 0
526
+ true
527
+ end
528
+
529
+ def select_search_active?
530
+ @select_state && @select_state[:search_active]
531
+ end
532
+
533
+ def select_editing_active?
534
+ select_search_active? || select_input_active?
535
+ end
536
+
537
+ def select_move_cursor_left
538
+ @composer.move_cursor_left
539
+ end
540
+
541
+ def select_move_cursor_right
542
+ @composer.move_cursor_right
543
+ end
544
+
545
+ def select_move_to_start_of_line
546
+ @composer.move_to_start_of_line
547
+ end
548
+
549
+ def select_move_to_end_of_line
550
+ @composer.move_to_end_of_line
551
+ end
552
+
553
+ def select_move_to_previous_word
554
+ @composer.move_to_previous_word
555
+ end
556
+
557
+ def select_move_to_next_word
558
+ @composer.move_to_next_word
559
+ end
560
+
561
+ def select_delete_word_before_cursor
562
+ reset_select_filter if @composer.delete_word_before_cursor
563
+ end
564
+
565
+ def select_delete_word_after_cursor
566
+ reset_select_filter if @composer.delete_word_after_cursor
567
+ end
568
+
569
+ def select_kill_line_before_cursor
570
+ reset_select_filter if @composer.kill_line_before_cursor
571
+ end
572
+
573
+ def select_kill_line_after_cursor
574
+ reset_select_filter if @composer.kill_line_after_cursor
575
+ end
576
+
577
+ def select_yank_kill_buffer
578
+ before = composer_input
579
+ @composer.yank_kill_buffer
580
+ reset_select_filter unless composer_input == before
581
+ end
582
+
147
583
  def select_insert_key(key)
148
584
  return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
149
585
 
@@ -153,29 +589,25 @@ module Kward
153
589
  def select_insert_string(string)
154
590
  return if string.empty?
155
591
 
156
- self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
157
- self.composer_cursor += string.length
158
- @select_state[:selection_index] = 0 if @select_state
592
+ @composer.insert_string(string)
593
+ reset_select_filter
159
594
  end
160
595
 
161
596
  def select_delete_before_cursor
162
- return unless composer_cursor.positive?
163
-
164
- self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
165
- self.composer_cursor -= 1
166
- @select_state[:selection_index] = 0 if @select_state
597
+ reset_select_filter if @composer.delete_before_cursor
167
598
  end
168
599
 
169
600
  def select_delete_at_cursor
170
- return unless composer_cursor < composer_input.length
601
+ reset_select_filter if @composer.delete_at_cursor
602
+ end
171
603
 
172
- self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
173
- @select_state[:selection_index] = 0 if @select_state
604
+ def reset_select_filter
605
+ @select_state[:selection_index] = 0 if @select_state && !select_input_active?
174
606
  end
175
607
 
176
608
  def selection_matches
177
609
  choices = @select_state ? @select_state[:choices] : []
178
- filter = composer_input.downcase.strip
610
+ filter = select_search_active? ? composer_input.downcase.strip : ""
179
611
  matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
180
612
  clamp_selection_index(matches.length)
181
613
  matches
@@ -192,22 +624,28 @@ module Kward
192
624
  @select_state[:selection_index] = count - 1 if count.positive? && selection_index >= count
193
625
  end
194
626
 
195
- def finish_select_prompt
627
+ def finish_select_prompt(render: true)
196
628
  @mutex.synchronize do
197
629
  @select_state = nil
198
630
  self.composer_input = ""
199
631
  self.composer_cursor = 0
200
632
  @asking = true
201
- render_prompt_locked
633
+ render_prompt_locked if render
202
634
  @output_io.flush
203
635
  end
204
636
  end
205
637
 
638
+ def select_deferred_finish_render?(result)
639
+ result.is_a?(Hash) && result[:defer_finish_render]
640
+ end
641
+
206
642
  def selection_overlay_rows(width, height: screen_height)
643
+ return selection_confirmation_rows(width) if select_confirmation_active?
644
+
207
645
  matches = selection_matches
208
- lines = [overlay_text_line("↑/↓ select · Enter open · Esc cancel", :muted), overlay_blank_line]
646
+ lines = [overlay_text_line(selection_overlay_help_text, :muted), overlay_blank_line]
209
647
  if matches.empty?
210
- if @select_state && @select_state[:custom] && !composer_input.strip.empty?
648
+ if @select_state && @select_state[:custom] && select_search_active? && !composer_input.strip.empty?
211
649
  lines << overlay_choice_line("Use custom: #{composer_input.strip}", selected: true)
212
650
  else
213
651
  lines << overlay_text_line("No matches", :muted)
@@ -224,11 +662,32 @@ module Kward
224
662
  overlay_card_rows(selection_overlay_title, lines, width)
225
663
  end
226
664
 
665
+ def selection_overlay_help_text
666
+ return "Renaming · Enter save · Esc cancel" if select_input_active?
667
+
668
+ text = "↑/↓ select · Enter open"
669
+ text = "#{text} · / search" unless select_search_active?
670
+ action_keys = @select_state ? @select_state[:action_keys].to_h : {}
671
+ action_keys.each do |key, action|
672
+ text = "#{text} · #{key} #{action[:action]}"
673
+ end
674
+ "#{text} · Esc cancel"
675
+ end
676
+
227
677
  def selection_overlay_title
228
678
  title = @select_state && @select_state[:title].to_s
229
679
  title && !title.empty? ? title : "Sessions"
230
680
  end
231
681
 
682
+ def selection_confirmation_rows(width)
683
+ title = @select_state[:confirm_title].to_s
684
+ title = "Confirm" if title.empty?
685
+ text = @select_state[:confirm_text].to_s
686
+ text = "Press #{@select_state[:confirm_key]} again to confirm, Esc to cancel." if text.empty?
687
+ lines = [overlay_text_line(text, :muted)]
688
+ overlay_card_rows(title, lines, width)
689
+ end
690
+
232
691
  def visible_selection_matches(matches, height: screen_height)
233
692
  max_rows = max_overlay_list_rows(height)
234
693
  start = centered_list_window_start(selection_index, matches.length, max_rows)