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