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
@@ -8,13 +8,7 @@ module Kward
8
8
 
9
9
  def ask_single_user_question(question, index, total)
10
10
  @mutex.synchronize do
11
- @prompt_label = "Answer>"
12
- self.composer_input = ""
13
- self.composer_cursor = 0
14
- @pending_keys.clear
15
- @asking = true
16
- @busy = false
17
- @queued_count = 0
11
+ prepare_modal_input_locked("Answer>")
18
12
  @question_state = {
19
13
  question: question[:question] || question["question"],
20
14
  header: question[:header] || question["header"],
@@ -23,7 +17,6 @@ module Kward
23
17
  index: index,
24
18
  total: total
25
19
  }
26
- reset_history_navigation
27
20
  render_prompt_locked
28
21
  end
29
22
 
@@ -37,6 +30,7 @@ module Kward
37
30
  render_prompt_locked if resized || footer_refreshed
38
31
  else
39
32
  result = handle_question_key(key)
33
+ result = drain_pending_question_keys_locked(result)
40
34
  render_prompt_locked unless result.is_a?(Hash) || result == SELECT_CANCEL
41
35
  end
42
36
  end
@@ -47,6 +41,13 @@ module Kward
47
41
  end
48
42
  end
49
43
 
44
+ def drain_pending_question_keys_locked(result)
45
+ until result.is_a?(Hash) || result == SELECT_CANCEL || @pending_keys.empty?
46
+ result = handle_question_key(@pending_keys.shift)
47
+ end
48
+ result
49
+ end
50
+
50
51
  def begin_question_prompt_state
51
52
  {
52
53
  prompt_label: @prompt_label,
@@ -64,6 +65,7 @@ module Kward
64
65
  def finish_question_prompt(saved_state)
65
66
  @mutex.synchronize do
66
67
  @question_state = nil
68
+ @question_prompt_active = false
67
69
  @select_state = saved_state[:select_state]
68
70
  @prompt_label = saved_state[:prompt_label]
69
71
  self.composer_input = saved_state[:input]
@@ -80,20 +82,17 @@ module Kward
80
82
 
81
83
  def handle_question_key(key)
82
84
  return if handle_question_bracketed_paste_key(key)
85
+ return if handle_question_shift_enter_key(key)
83
86
 
84
87
  csi_result = handle_question_csi_u_key(key)
85
88
  return csi_result unless csi_result == false
86
89
 
87
- if key.is_a?(String) && key.length > 1
88
- token = next_key_token(key)
89
- if token.length < key.length
90
- queue_pending_keys(key[token.length..])
91
- return handle_question_key(token)
92
- end
93
- end
90
+ return true if handle_bundled_key(key) { |token| handle_question_key(token) }
91
+
92
+ binding_result = handle_question_composer_key_binding(key)
93
+ return binding_result unless binding_result == false
94
94
 
95
- key_name = @reader.console.keys[key]
96
- case key_name
95
+ case key_name_for(key)
97
96
  when :return, :enter
98
97
  current_question_answer
99
98
  when :backspace
@@ -101,13 +100,13 @@ module Kward
101
100
  when :delete
102
101
  question_delete_at_cursor
103
102
  when :left
104
- self.composer_cursor -= 1 if composer_cursor.positive?
103
+ move_cursor_left
105
104
  when :right
106
- self.composer_cursor += 1 if composer_cursor < composer_input.length
105
+ move_cursor_right
107
106
  when :home
108
- self.composer_cursor = 0
107
+ move_to_start_of_line
109
108
  when :end
110
- self.composer_cursor = composer_input.length
109
+ move_to_end_of_line
111
110
  when :up
112
111
  question_previous_choice
113
112
  when :down
@@ -127,59 +126,80 @@ module Kward
127
126
  end
128
127
 
129
128
  def handle_question_csi_u_key(key)
130
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
131
- return false unless match
129
+ sequence = parse_csi_u_key(key)
130
+ return false unless sequence
132
131
 
133
- sequence = match[0]
134
- code = match[1].to_i
135
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
132
+ code = sequence[:code]
133
+ modifier = sequence[:modifier]
134
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
136
135
 
137
136
  case code
138
137
  when 13
139
- current_question_answer
138
+ if modifier == 2
139
+ question_insert_string("\n")
140
+ nil
141
+ else
142
+ current_question_answer
143
+ end
140
144
  when 27
141
145
  SELECT_CANCEL
142
146
  when 8, 127
143
- question_delete_before_cursor
147
+ alt_modifier?(modifier) ? question_delete_word_before_cursor : question_delete_before_cursor
144
148
  nil
145
149
  else
146
- false
150
+ modified_result = handle_question_modified_csi_u_key(code, modifier)
151
+ return modified_result unless modified_result == false
152
+
153
+ question_insert_csi_u_text(sequence)
147
154
  end
148
155
  end
149
156
 
157
+ def handle_question_modified_csi_u_key(code, modifier)
158
+ before = composer_input.dup
159
+ result = handle_modified_csi_u_key(code, modifier)
160
+ question_select_custom_choice if result != false && composer_input != before
161
+ result
162
+ end
163
+
164
+ def question_insert_csi_u_text(sequence)
165
+ text = csi_u_printable_text(sequence)
166
+ return true if text.nil? && csi_u_text_field?(sequence)
167
+ return false unless text
168
+
169
+ question_insert_string(text)
170
+ end
171
+
150
172
  def handle_question_escape_sequence
151
- sequence = read_pending_escape_sequence
152
- return SELECT_CANCEL if sequence.empty?
173
+ pending_sequence = read_pending_escape_sequence
174
+ return SELECT_CANCEL if pending_sequence.empty?
153
175
 
154
- key_name = @reader.console.keys["\e#{sequence}"]
155
- case key_name
176
+ full_sequence = "\e#{pending_sequence}"
177
+ sequence = next_key_token(full_sequence)
178
+ queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
179
+ return SELECT_CANCEL if sequence == "\e"
180
+
181
+ binding_result = handle_question_composer_key_binding(sequence)
182
+ return binding_result unless binding_result == false
183
+
184
+ case key_name_for(sequence)
156
185
  when :up
157
186
  question_previous_choice
158
187
  when :down
159
188
  question_next_choice
160
189
  when :left
161
- self.composer_cursor -= 1 if composer_cursor.positive?
190
+ move_cursor_left
162
191
  when :right
163
- self.composer_cursor += 1 if composer_cursor < composer_input.length
192
+ move_cursor_right
164
193
  end
165
194
  true
166
195
  end
167
196
 
168
197
  def handle_question_bracketed_paste_key(key)
169
- text = key.to_s
170
- return false unless text.start_with?(BRACKETED_PASTE_START)
171
-
172
- pasted = text[BRACKETED_PASTE_START.length..] || ""
173
- until pasted.include?(BRACKETED_PASTE_END)
174
- chunk = @reader.read_keypress(echo: false, raw: true)
175
- break if chunk.nil?
198
+ paste = read_bracketed_paste(key)
199
+ return false unless paste
176
200
 
177
- pasted << chunk.to_s
178
- end
179
-
180
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
181
- question_insert_string(normalize_paste(content || ""))
182
- queue_pending_keys(remaining) if remaining && !remaining.empty?
201
+ question_insert_string(normalize_paste(paste[:content]))
202
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
183
203
  true
184
204
  end
185
205
 
@@ -248,38 +268,78 @@ module Kward
248
268
  question_insert_string(key)
249
269
  end
250
270
 
271
+ def handle_question_shift_enter_key(key)
272
+ sequence = shift_enter_sequence_for(key)
273
+ return false unless sequence
274
+
275
+ question_insert_string("\n")
276
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
277
+ true
278
+ end
279
+
280
+ def handle_question_composer_key_binding(key)
281
+ before = composer_input.dup
282
+ result = handle_composer_key_binding(key)
283
+ question_select_custom_choice if result != false && composer_input != before
284
+ result
285
+ end
286
+
251
287
  def question_insert_string(string)
252
288
  return if string.empty?
253
289
 
254
- self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
255
- self.composer_cursor += string.length
256
- @question_state[:selection_index] = question_choices.length - 1 if @question_state
290
+ insert_string(string)
291
+ question_select_custom_choice
257
292
  end
258
293
 
259
294
  def question_delete_before_cursor
260
- return unless composer_cursor.positive?
261
-
262
- self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
263
- self.composer_cursor -= 1
264
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
295
+ before = composer_input.dup
296
+ delete_before_cursor
297
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
265
298
  end
266
299
 
267
300
  def question_delete_at_cursor
268
- return unless composer_cursor < composer_input.length
301
+ before = composer_input.dup
302
+ delete_at_cursor
303
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
304
+ end
269
305
 
270
- self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
271
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
306
+ def question_delete_word_before_cursor
307
+ before = composer_input.dup
308
+ delete_word_before_cursor
309
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
310
+ end
311
+
312
+ def question_select_custom_choice
313
+ @question_state[:selection_index] = question_choices.length - 1 if @question_state
272
314
  end
273
315
 
274
316
  def question_composer_layout(width, height = screen_height)
275
317
  content_width = [width - 4, 1].max
276
318
  overlay_rows = active_overlay_rows(width, height: height)
277
- rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
278
- return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
319
+ return question_custom_composer_layout(width, height, overlay_rows, content_width) if selected_question_choice&.fetch(:custom, false)
279
320
 
321
+ rows = overlay_rows + [top_border(width), box_content_row("", content_width)]
322
+ rows.concat(question_bottom_border_rows(width))
280
323
  [rows, overlay_rows.length + 1, 2]
281
324
  end
282
325
 
326
+ def question_custom_composer_layout(width, height, overlay_rows, content_width)
327
+ input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
328
+ max_input_rows = max_visible_input_rows(0, overlay_rows.length, 0, height: height)
329
+ visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
330
+ visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
331
+ rows = overlay_rows + [top_border(width)]
332
+ rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
333
+ rows.concat(question_bottom_border_rows(width))
334
+ cursor_row = overlay_rows.length + 1 + input_cursor_row - visible_start
335
+ cursor_col = 2 + [input_cursor_col, content_width - 1].min
336
+ [rows, cursor_row, cursor_col]
337
+ end
338
+
339
+ def question_bottom_border_rows(width)
340
+ @tabs.empty? ? [bottom_border(width)] : tab_border_rows(width)
341
+ end
342
+
283
343
  def question_overlay_rows(width)
284
344
  title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
285
345
  lines = [
@@ -294,35 +354,15 @@ module Kward
294
354
  overlay_card_rows(title, lines, width)
295
355
  end
296
356
 
297
- def question_custom_cursor_row
298
- 4 + question_choices.index { |choice| choice[:custom] }.to_i
299
- end
300
-
301
- def question_custom_cursor_col(width)
302
- card_width = overlay_card_width(width)
303
- left_padding = overlay_left_padding(width, card_width)
304
- custom_prefix = selected_question_choice&.fetch(:custom, false) || !composer_input.empty? ? "Type something: " : "Type something."
305
- visible_before_cursor = display_question_input(composer_input[0...composer_cursor])
306
- [[left_padding + 2 + 2 + custom_prefix.length + visible_before_cursor.length, width - 1].min, 0].max
307
- end
308
-
309
357
  def choice_text(choice, selected: false)
310
358
  if choice[:custom]
311
- if selected || !composer_input.empty?
312
- "Type something: #{display_question_input(composer_input)}"
313
- else
314
- "Type something."
315
- end
359
+ selected ? "Type a custom answer below." : "Type something."
316
360
  else
317
361
  description = choice[:description].empty? ? "" : " — #{choice[:description]}"
318
362
  "#{choice[:label]}#{description}"
319
363
  end
320
364
  end
321
365
 
322
- def display_question_input(value)
323
- value.to_s.gsub(/\s+/, " ")
324
- end
325
-
326
366
  end
327
367
  end
328
368
  end
@@ -17,7 +17,7 @@ module Kward
17
17
  end
18
18
 
19
19
  def tick_spinner_locked
20
- return false unless @busy && @queued_count.zero? && @started && @asking
20
+ return false unless spinner_active?
21
21
 
22
22
  now = monotonic_now
23
23
  elapsed = now - @last_spinner_tick
@@ -33,6 +33,11 @@ module Kward
33
33
  SPINNER_FRAMES[@spinner_frame_index % SPINNER_FRAMES.length]
34
34
  end
35
35
 
36
+ def spinner_active?
37
+ busy = @busy || (@select_state && @select_state[:busy_activity])
38
+ busy && @queued_count.zero? && @started && @asking
39
+ end
40
+
36
41
  def tick_footer_locked
37
42
  return false unless @footer && @started && @asking
38
43
 
@@ -45,6 +50,21 @@ module Kward
45
50
  true
46
51
  end
47
52
 
53
+ def cached_composer_status_text
54
+ return nil unless @composer_status
55
+
56
+ now = monotonic_now
57
+ elapsed = now - @last_composer_status_refresh.to_f
58
+ if @cached_composer_status_text.nil? || elapsed >= COMPOSER_STATUS_REFRESH_INTERVAL
59
+ text = @composer_status.call.to_s
60
+ @cached_composer_status_text = text.empty? ? nil : status_composer_text(text)
61
+ @last_composer_status_refresh = now
62
+ end
63
+ @cached_composer_status_text
64
+ rescue StandardError
65
+ @cached_composer_status_text = nil
66
+ end
67
+
48
68
  def monotonic_now
49
69
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
50
70
  end
@@ -54,6 +74,34 @@ module Kward
54
74
  ANSI.colorize(text, *styles, enabled: @color_enabled)
55
75
  end
56
76
 
77
+ def normalize_tab_keybindings(value)
78
+ text = value.to_s.downcase
79
+ return "ctrl" if text == "ctrl"
80
+ return "alt" if text == "alt"
81
+
82
+ RbConfig::CONFIG["host_os"].to_s.downcase.include?("darwin") ? "ctrl" : "alt"
83
+ end
84
+
85
+ def normalize_editor_mode(value)
86
+ EditorMode.normalize(value)
87
+ end
88
+
89
+ def normalize_editor_line_numbers(value)
90
+ EditorMode.normalize_line_numbers(value)
91
+ end
92
+
93
+ def tab_action_result?(result)
94
+ result.is_a?(Hash) && result[:tab_action]
95
+ end
96
+
97
+ def reasoning_action_result?(result)
98
+ result.is_a?(Hash) && result[:reasoning_action]
99
+ end
100
+
101
+ def prompt_action_result?(result)
102
+ tab_action_result?(result) || reasoning_action_result?(result)
103
+ end
104
+
57
105
  end
58
106
  end
59
107
  end
@@ -55,6 +55,9 @@ module Kward
55
55
 
56
56
  def render_cursor_visibility_locked
57
57
  visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
58
+ visible = select_editing_active? if @select_state
59
+ visible = git_composing? if @git_state
60
+ visible = project_browser_search_active? if project_browser_visible?
58
61
  set_cursor_visible_locked(visible)
59
62
  end
60
63
 
@@ -65,6 +68,20 @@ module Kward
65
68
  @cursor_visible = visible
66
69
  end
67
70
 
71
+ def set_editor_bar_cursor_locked
72
+ return if @editor_bar_cursor_active
73
+
74
+ @output_io.print(CURSOR_SHAPE_BAR)
75
+ @editor_bar_cursor_active = true
76
+ end
77
+
78
+ def restore_editor_cursor_shape_locked
79
+ return unless @editor_bar_cursor_active
80
+
81
+ @output_io.print(CURSOR_SHAPE_DEFAULT)
82
+ @editor_bar_cursor_active = false
83
+ end
84
+
68
85
  def reserve_composer_region_locked(width: screen_width, height: screen_height)
69
86
  rows, = composer_layout(width, height)
70
87
  ensure_scroll_region_locked(rows.length, width: width, height: height)