kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -10,35 +10,52 @@ 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
- "\x03"
17
+ TerminalKeys::CTRL_C
16
18
  rescue IO::WaitReadable, Errno::EAGAIN, Errno::EWOULDBLOCK
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
- when "\x04"
119
+ when TerminalKeys::CTRL_D
89
120
  delete_at_cursor_or_exit
90
- when "\x03"
121
+ when TerminalKeys::CTRL_C
91
122
  cancel_input_or_interrupt
123
+ when TerminalKeys::CTRL_R
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 TerminalKeys::CTRL_C, "\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
- raise Interrupt
196
+ true
197
+ end
198
+
199
+ def handle_tab_completion_key
200
+ open_selected_file_in_editor || complete_selected_file_mention || complete_selected_slash_command || insert_key("\t")
104
201
  end
105
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,14 +253,43 @@ module Kward
137
253
  end
138
254
 
139
255
  def handle_bracketed_paste_key(key)
256
+ handle_bracketed_paste(key) { |content| insert_paste(content) }
257
+ end
258
+
259
+ def handle_mouse_reporting_key(key)
260
+ event = parse_sgr_mouse_event(key)
261
+ return false unless event
262
+
263
+ queue_pending_keys(event[:remaining]) unless event[:remaining].empty?
264
+ true
265
+ end
266
+
267
+ def handle_bracketed_paste(key)
140
268
  paste = read_bracketed_paste(key)
141
269
  return false unless paste
142
270
 
143
- insert_paste(normalize_paste(paste[:content]))
271
+ yield normalize_paste(paste[:content])
144
272
  queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
145
273
  true
146
274
  end
147
275
 
276
+ def parse_sgr_mouse_event(key)
277
+ match = key.to_s.match(/\A(?:\e)?\[<(\d+);(\d+);(\d+)([Mm])/)
278
+ return nil unless match
279
+
280
+ code = match[1].to_i
281
+ {
282
+ code: code,
283
+ button: code & 3,
284
+ column: match[2].to_i,
285
+ row: match[3].to_i,
286
+ action: match[4],
287
+ release: match[4] == "m",
288
+ drag: (code & 32).positive?,
289
+ remaining: key.to_s[match[0].length..].to_s
290
+ }
291
+ end
292
+
148
293
  def read_bracketed_paste(key)
149
294
  text = key.to_s
150
295
  return nil unless text.start_with?(BRACKETED_PASTE_START)
@@ -174,39 +319,115 @@ module Kward
174
319
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
175
320
 
176
321
  case code
322
+ when 9
323
+ if ctrl_modifier?(modifier)
324
+ shift_modifier?(modifier) ? { tab_action: :previous } : { tab_action: :next }
325
+ elsif shift_modifier?(modifier)
326
+ handle_reasoning_key_binding(key) || handle_tab_completion_key
327
+ else
328
+ completion_result = handle_completion_provider_key("\t")
329
+ completion_result == false ? handle_reasoning_key_binding("\t") || handle_tab_completion_key : completion_result
330
+ end
177
331
  when 13
178
- modifier == 2 ? insert_string("\n") : submit_input
332
+ if modifier == 2
333
+ insert_string("\n")
334
+ elsif file_open_overlay_visible?
335
+ open_selected_file_in_editor(fallback_to_typed_path: true)
336
+ else
337
+ submit_input
338
+ end
179
339
  when 27
180
- dismiss_slash_overlay || false
340
+ dismiss_file_overlay || dismiss_slash_overlay || false
181
341
  when 8, 127
182
342
  alt_modifier?(modifier) ? delete_word_before_cursor : delete_before_cursor
183
343
  nil
184
344
  when 4
185
345
  delete_at_cursor_or_exit
186
346
  else
187
- handle_modified_csi_u_key(code, modifier)
347
+ handle_modified_csi_u_key(code, modifier) || insert_csi_u_text(sequence)
188
348
  end
189
349
  end
190
350
 
191
351
  def parse_csi_u_key(key)
192
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
352
+ match = key.to_s.match(TerminalKeys::CSI_U_PATTERN)
193
353
  return nil unless match
194
354
 
195
- modifiers = match[2].to_s
355
+ fields = match[2].to_s.split(";", -1)[1..] || []
356
+ modifiers = fields[0].to_s
196
357
  modifier = (modifiers.empty? ? "1" : modifiers).split(":", 2).first.to_i
197
358
  {
198
359
  sequence: match[0],
199
360
  code: match[1].to_i,
200
361
  modifiers: modifiers,
201
362
  modifier: modifier,
363
+ text: fields[1].to_s,
202
364
  remaining: key.to_s[match[0].length..]
203
365
  }
204
366
  end
205
367
 
368
+ def csi_u_key_event(sequence)
369
+ code = sequence[:code]
370
+ case code
371
+ when 9
372
+ { type: :tab, modifier: sequence[:modifier] }
373
+ when 13
374
+ { type: :enter, modifier: sequence[:modifier] }
375
+ when 27
376
+ { type: :escape, modifier: sequence[:modifier] }
377
+ when 8, 127
378
+ { type: :backspace, modifier: sequence[:modifier] }
379
+ when 4
380
+ { type: :delete, modifier: sequence[:modifier] }
381
+ else
382
+ text = csi_u_printable_text(sequence)
383
+ return { type: :printable, text: text, modifier: sequence[:modifier] } if text
384
+ return { type: :text_field, modifier: sequence[:modifier] } if csi_u_text_field?(sequence)
385
+
386
+ { type: :modified, code: code, modifier: sequence[:modifier] }
387
+ end
388
+ end
389
+
390
+ def insert_csi_u_text(sequence)
391
+ event = csi_u_key_event(sequence)
392
+ return true if event[:type] == :text_field
393
+ return false unless event[:type] == :printable
394
+
395
+ insert_string(event[:text])
396
+ end
397
+
398
+ def csi_u_text_field?(sequence)
399
+ !sequence[:text].to_s.empty?
400
+ end
401
+
402
+ def csi_u_printable_text(sequence)
403
+ text = csi_u_text(sequence)
404
+ return text unless text.empty?
405
+ return nil if csi_u_text_field?(sequence)
406
+ return nil if ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])
407
+ return nil unless sequence[:code].between?(32, 126)
408
+
409
+ sequence[:code].chr(Encoding::UTF_8)
410
+ end
411
+
412
+ def csi_u_text(sequence)
413
+ sequence[:text].to_s.split(":").map do |codepoint|
414
+ character = csi_u_codepoint_character(codepoint)
415
+ return "" unless character
416
+
417
+ character
418
+ end.join
419
+ end
420
+
421
+ def csi_u_codepoint_character(codepoint)
422
+ codepoint.to_i.chr(Encoding::UTF_8)
423
+ rescue RangeError
424
+ nil
425
+ end
426
+
206
427
  def handle_modified_csi_u_key(code, modifier)
207
428
  return false unless ctrl_modifier?(modifier) || alt_modifier?(modifier)
208
429
 
209
- normalized_code = code.to_i.chr.downcase.ord rescue code
430
+ normalized_code = ctrl_code_for(code)
210
431
  if ctrl_modifier?(modifier)
211
432
  case normalized_code
212
433
  when 97
@@ -227,6 +448,8 @@ module Kward
227
448
  kill_line_after_cursor
228
449
  when 108
229
450
  redraw_screen_locked
451
+ when 114
452
+ start_history_search
230
453
  when 117
231
454
  kill_line_before_cursor
232
455
  when 119
@@ -252,6 +475,32 @@ module Kward
252
475
  end
253
476
  end
254
477
 
478
+ def ctrl_code_for(code)
479
+ code.to_i.chr.downcase.ord rescue code
480
+ end
481
+
482
+ def key_name_for(key)
483
+ cursor_key_name(key) || @reader.console.keys[key]
484
+ end
485
+
486
+ def cursor_key_name(key)
487
+ text = key.to_s
488
+ case text
489
+ when TerminalKeys::UP_PATTERN, *TerminalKeys::UP
490
+ :up
491
+ when TerminalKeys::DOWN_PATTERN, *TerminalKeys::DOWN
492
+ :down
493
+ when TerminalKeys::RIGHT_PATTERN, *TerminalKeys::RIGHT
494
+ :right
495
+ when TerminalKeys::LEFT_PATTERN, *TerminalKeys::LEFT
496
+ :left
497
+ when *TerminalKeys::PAGE_UP
498
+ :pageup
499
+ when *TerminalKeys::PAGE_DOWN
500
+ :pagedown
501
+ end
502
+ end
503
+
255
504
  def ctrl_modifier?(modifier)
256
505
  ((modifier.to_i - 1) & 4).positive?
257
506
  end
@@ -260,6 +509,14 @@ module Kward
260
509
  ((modifier.to_i - 1) & 2).positive?
261
510
  end
262
511
 
512
+ def super_modifier?(modifier)
513
+ ((modifier.to_i - 1) & 8).positive?
514
+ end
515
+
516
+ def shift_modifier?(modifier)
517
+ ((modifier.to_i - 1) & 1).positive?
518
+ end
519
+
263
520
  def handle_shift_enter_key(key)
264
521
  sequence = shift_enter_sequence_for(key)
265
522
  return false unless sequence
@@ -278,17 +535,28 @@ module Kward
278
535
  end
279
536
  end
280
537
 
538
+ def handle_bundled_key(key)
539
+ return false unless key.is_a?(String) && key.length > 1
540
+
541
+ token = next_key_token(key)
542
+ return false unless token.length < key.length
543
+
544
+ queue_pending_keys(key[token.length..])
545
+ yield token
546
+ true
547
+ end
548
+
281
549
  def next_key_token(keys)
282
550
  text = keys.to_s
283
- text.match(/\A\e\[[0-9;:]*[A-Za-z~]/)&.[](0) ||
284
- text.match(/\A\eO[A-Za-z]/)&.[](0) ||
551
+ text.match(TerminalKeys::CSI_KEY_PATTERN)&.[](0) ||
552
+ text.match(TerminalKeys::SS3_KEY_PATTERN)&.[](0) ||
285
553
  shift_enter_sequence_for(text) ||
286
554
  (text.start_with?("\e") && text.length > 1 && alt_key_sequence?(text[1]) ? text[0, 2] : text[0, 1])
287
555
  end
288
556
 
289
557
  def alt_key_sequence?(char)
290
558
  char = char.to_s
291
- char.match?(/[[:alpha:]]/) || char == "\b" || char == "\x7F"
559
+ char.match?(/[[:alnum:]]/) || char == "\b" || char == "\x7F"
292
560
  end
293
561
 
294
562
  def shift_enter_sequence_for(key)
@@ -310,37 +578,154 @@ module Kward
310
578
  sequence
311
579
  end
312
580
 
581
+ CTRL_TAB_SEQUENCES = TerminalKeys::CTRL_TAB
582
+ CTRL_SHIFT_TAB_SEQUENCES = TerminalKeys::CTRL_SHIFT_TAB
583
+ SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
584
+
585
+ def handle_completion_provider_key(key)
586
+ return false unless key == "\t" && @completion_provider
587
+
588
+ result = @completion_provider.call(composer_input, composer_cursor)
589
+ return true unless result
590
+
591
+ apply_completion_result(result)
592
+ true
593
+ end
594
+
595
+ def apply_completion_result(result)
596
+ range = result[:range] || result["range"] || result.range
597
+ replacement = result[:replacement] || result["replacement"] || result.replacement
598
+ candidates = result[:candidates] || result["candidates"] || result.candidates
599
+ original = composer_input
600
+ before = original[0...range.begin].to_s
601
+ after = original[range.end..].to_s
602
+ self.composer_input = "#{before}#{replacement}#{after}"
603
+ self.composer_cursor = before.length + replacement.to_s.length
604
+ show_completion_candidates(candidates, replacement) if candidates.to_a.length > 1 && replacement.to_s == original[range]
605
+ end
606
+
607
+ def show_completion_candidates(candidates, replacement)
608
+ lines = candidates.to_a.first(40)
609
+ text = ["completions:", *lines.map { |candidate| " #{candidate}" }].join("\n")
610
+ write_completion_transcript_locked(text)
611
+ end
612
+
613
+ def write_completion_transcript_locked(text)
614
+ with_synchronized_output_locked do
615
+ clear_prompt_for_output_locked
616
+ write_transcript_text_locked("\n#{text}\n")
617
+ render_prompt_after_output_locked
618
+ end
619
+ end
620
+
621
+ def handle_reasoning_key_binding(key)
622
+ return false if @busy || @select_state || @question_state
623
+ return false if file_overlay_visible? || slash_overlay_visible?
624
+ return false if @slash_overlay_dismissed_input && @slash_overlay_dismissed_input == composer_input
625
+ mention_token = active_file_mention_token
626
+ open_token = active_file_open_token
627
+ return false if mention_token && @file_overlay_dismissed_token == mention_token
628
+ return false if open_token && @file_open_dismissed_token == open_token
629
+
630
+ case key
631
+ when "\t"
632
+ { reasoning_action: :next }
633
+ when *SHIFT_TAB_SEQUENCES
634
+ { reasoning_action: :previous }
635
+ else
636
+ false
637
+ end
638
+ end
639
+
640
+ def handle_tab_key_binding(key)
641
+ return false if @select_state || @question_state || @tabs.empty?
642
+
643
+ navigation_result = handle_ctrl_tab_navigation_key_binding(key)
644
+ return navigation_result unless navigation_result == false
645
+
646
+ @tab_keybindings == "ctrl" ? handle_ctrl_tab_key_binding(key) : handle_alt_tab_key_binding(key)
647
+ end
648
+
649
+ def handle_ctrl_tab_navigation_key_binding(key)
650
+ case key
651
+ when *CTRL_TAB_SEQUENCES
652
+ { tab_action: :next }
653
+ when *CTRL_SHIFT_TAB_SEQUENCES
654
+ { tab_action: :previous }
655
+ else
656
+ false
657
+ end
658
+ end
659
+
660
+ def handle_ctrl_tab_key_binding(key)
661
+ case key
662
+ when TerminalKeys::CTRL_T, TerminalKeys::CTRL_T_CSI_U
663
+ { tab_action: :new }
664
+ when TerminalKeys::CTRL_W_CSI_U
665
+ { tab_action: :close }
666
+ else
667
+ ctrl_number_tab_action(key)
668
+ end
669
+ end
670
+
671
+ def ctrl_number_tab_action(key)
672
+ match = key.to_s.match(TerminalKeys::CTRL_NUMBER_TAB_PATTERN)
673
+ return false unless match
674
+
675
+ { tab_action: :select, index: match[1].to_i - 49 }
676
+ end
677
+
678
+ def handle_alt_tab_key_binding(key)
679
+ case key
680
+ when "\et", "\eT"
681
+ { tab_action: :new }
682
+ when *TerminalKeys::ALT_RIGHT
683
+ { tab_action: :next }
684
+ when *TerminalKeys::ALT_LEFT
685
+ { tab_action: :previous }
686
+ else
687
+ alt_number_tab_action(key)
688
+ end
689
+ end
690
+
691
+ def alt_number_tab_action(key)
692
+ match = key.to_s.match(/\A\e([1-9])\z/)
693
+ return false unless match
694
+
695
+ { tab_action: :select, index: match[1].to_i - 1 }
696
+ end
697
+
313
698
  def handle_composer_key_binding(key)
314
699
  case key
315
- when "\x01"
700
+ when TerminalKeys::CTRL_A
316
701
  move_to_start_of_line
317
- when "\x02"
702
+ when TerminalKeys::CTRL_B
318
703
  move_cursor_left
319
- when "\x04"
704
+ when TerminalKeys::CTRL_D
320
705
  delete_at_cursor_or_exit
321
- when "\x05"
706
+ when TerminalKeys::CTRL_E
322
707
  move_to_end_of_line
323
- when "\x06"
708
+ when TerminalKeys::CTRL_F
324
709
  move_cursor_right
325
- when "\x0B"
710
+ when TerminalKeys::CTRL_K
326
711
  kill_line_after_cursor
327
- when "\x0C"
712
+ when TerminalKeys::CTRL_L
328
713
  redraw_screen_locked
329
- when "\x15"
714
+ when TerminalKeys::CTRL_U
330
715
  kill_line_before_cursor
331
- when "\x17"
716
+ when TerminalKeys::CTRL_W
332
717
  delete_word_before_cursor
333
- when "\x19"
718
+ when TerminalKeys::CTRL_Y
334
719
  yank_kill_buffer
335
- when "\e[D", "\eOD"
720
+ when *TerminalKeys::LEFT
336
721
  move_cursor_left
337
- when "\e[C", "\eOC"
722
+ when *TerminalKeys::RIGHT
338
723
  move_cursor_right
339
- when "\e[H", "\eOH", "\e[1~", "\e[7~"
724
+ when *TerminalKeys::HOME
340
725
  move_to_start_of_line
341
- when "\e[F", "\eOF", "\e[4~", "\e[8~"
726
+ when *TerminalKeys::END_KEY
342
727
  move_to_end_of_line
343
- when "\e[3~"
728
+ when *TerminalKeys::DELETE
344
729
  delete_at_cursor
345
730
  when "\eb", "\eB"
346
731
  move_to_previous_word
@@ -355,14 +740,23 @@ module Kward
355
740
  end
356
741
  end
357
742
 
743
+ def parse_modified_ansi_key(key)
744
+ if (match = key.to_s.match(TerminalKeys::MODIFIED_CURSOR_PATTERN))
745
+ { type: :cursor, modifier: match[2].to_i, final: match[3] }
746
+ elsif (match = key.to_s.match(TerminalKeys::MODIFIED_DELETE_PATTERN))
747
+ { type: :delete, modifier: match[1].to_i }
748
+ end
749
+ end
750
+
358
751
  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)
752
+ sequence = parse_modified_ansi_key(key)
753
+ return false unless sequence
364
754
 
365
- case final
755
+ case sequence[:type]
756
+ when :cursor
757
+ return false unless alt_modifier?(sequence[:modifier])
758
+
759
+ case sequence[:final]
366
760
  when "C"
367
761
  move_to_next_word
368
762
  when "D"
@@ -374,8 +768,8 @@ module Kward
374
768
  else
375
769
  false
376
770
  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
771
+ when :delete
772
+ alt_modifier?(sequence[:modifier]) ? delete_word_after_cursor : delete_at_cursor
379
773
  else
380
774
  false
381
775
  end