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,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
@@ -137,8 +253,26 @@ module Kward
137
253
  end
138
254
 
139
255
  def handle_bracketed_paste_key(key)
256
+ paste = read_bracketed_paste(key)
257
+ return false unless paste
258
+
259
+ insert_paste(normalize_paste(paste[:content]))
260
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
261
+ true
262
+ end
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
+
273
+ def read_bracketed_paste(key)
140
274
  text = key.to_s
141
- return false unless text.start_with?(BRACKETED_PASTE_START)
275
+ return nil unless text.start_with?(BRACKETED_PASTE_START)
142
276
 
143
277
  pasted = text[BRACKETED_PASTE_START.length..] || ""
144
278
  until pasted.include?(BRACKETED_PASTE_END)
@@ -149,9 +283,7 @@ module Kward
149
283
  end
150
284
 
151
285
  content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
152
- insert_paste(normalize_paste(content || ""))
153
- queue_pending_keys(remaining) if remaining && !remaining.empty?
154
- true
286
+ { content: content || "", remaining: remaining }
155
287
  end
156
288
 
157
289
  def normalize_paste(content)
@@ -159,33 +291,101 @@ module Kward
159
291
  end
160
292
 
161
293
  def handle_csi_u_key(key)
162
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
163
- return false unless match
294
+ sequence = parse_csi_u_key(key)
295
+ return false unless sequence
164
296
 
165
- sequence = match[0]
166
- code = match[1].to_i
167
- modifier = (match[2] || "1").split(":", 2).first.to_i
168
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
297
+ code = sequence[:code]
298
+ modifier = sequence[:modifier]
299
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
169
300
 
170
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
171
311
  when 13
172
- 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
173
319
  when 27
174
- dismiss_slash_overlay || false
320
+ dismiss_file_overlay || dismiss_slash_overlay || false
175
321
  when 8, 127
176
322
  alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
177
323
  nil
178
324
  when 4
179
325
  delete_at_cursor_or_exit
180
326
  else
181
- handle_modified_csi_u_key(code, modifier)
327
+ handle_modified_csi_u_key(code, modifier) || insert_csi_u_text(sequence)
182
328
  end
183
329
  end
184
330
 
331
+ def parse_csi_u_key(key)
332
+ match = key.to_s.match(/\A\e\[(\d+)((?:;[\d:]*)*)u/)
333
+ return nil unless match
334
+
335
+ fields = match[2].to_s.split(";", -1)[1..] || []
336
+ modifiers = fields[0].to_s
337
+ modifier = (modifiers.empty? ? "1" : modifiers).split(":", 2).first.to_i
338
+ {
339
+ sequence: match[0],
340
+ code: match[1].to_i,
341
+ modifiers: modifiers,
342
+ modifier: modifier,
343
+ text: fields[1].to_s,
344
+ remaining: key.to_s[match[0].length..]
345
+ }
346
+ end
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
+
185
385
  def handle_modified_csi_u_key(code, modifier)
186
386
  return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
187
387
 
188
- normalized_code = code.to_i.chr.downcase.ord rescue code
388
+ normalized_code = ctrl_code_for(code)
189
389
  if ctrl_modifier?(modifier)
190
390
  case normalized_code
191
391
  when 97
@@ -206,6 +406,8 @@ module Kward
206
406
  kill_line_after_cursor
207
407
  when 108
208
408
  redraw_screen_locked
409
+ when 114
410
+ start_history_search
209
411
  when 117
210
412
  kill_line_before_cursor
211
413
  when 119
@@ -231,6 +433,32 @@ module Kward
231
433
  end
232
434
  end
233
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
+
234
462
  def ctrl_modifier?(modifier)
235
463
  ((modifier.to_i - 1) & 4).positive?
236
464
  end
@@ -239,6 +467,14 @@ module Kward
239
467
  ((modifier.to_i - 1) & 2).positive?
240
468
  end
241
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
+
242
478
  def handle_shift_enter_key(key)
243
479
  sequence = shift_enter_sequence_for(key)
244
480
  return false unless sequence
@@ -257,6 +493,17 @@ module Kward
257
493
  end
258
494
  end
259
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
+
260
507
  def next_key_token(keys)
261
508
  text = keys.to_s
262
509
  text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
@@ -267,7 +514,7 @@ module Kward
267
514
 
268
515
  def alt_key_sequence?(char)
269
516
  char = char.to_s
270
- char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
517
+ char.match?(/[[:alnum:]]/) || char == "\b" || char == "\x7F"
271
518
  end
272
519
 
273
520
  def shift_enter_sequence_for(key)
@@ -289,6 +536,123 @@ module Kward
289
536
  sequence
290
537
  end
291
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
+
292
656
  def handle_composer_key_binding(key)
293
657
  case key
294
658
  when "\x01"
@@ -334,14 +698,23 @@ module Kward
334
698
  end
335
699
  end
336
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
+
337
709
  def handle_modified_ansi_key(key)
338
- match = key.to_s.match(/\A\e\[(\d+);(\d+)([CDFH])\z/)
339
- if match
340
- modifier = match[2].to_i
341
- final = match[3]
342
- 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])
343
716
 
344
- case final
717
+ case sequence[:final]
345
718
  when "C"
346
719
  move_to_next_word
347
720
  when "D"
@@ -353,8 +726,8 @@ module Kward
353
726
  else
354
727
  false
355
728
  end
356
- elsif (match = key.to_s.match(/\A\e\[3;(\d+)~\z/))
357
- 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
358
731
  else
359
732
  false
360
733
  end
@@ -6,8 +6,8 @@ module Kward
6
6
  module Layout
7
7
  private
8
8
 
9
- def banner_rows(width)
10
- @banner.rows(width)
9
+ def banner_rows(width, message: nil)
10
+ @banner.rows(width, message: message)
11
11
  end
12
12
 
13
13
  def banner_logo_rows
@@ -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