kward 0.71.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -43,9 +43,22 @@ module Kward
43
43
  end
44
44
 
45
45
  def build_interactive_agent(conversation)
46
+ @active_worker_role = "implementation"
47
+ set_visible_worker("implementation", status: "active")
48
+ build_worker_agent(conversation, role: "implementation")
49
+ end
50
+
51
+ def build_worker_agent(conversation, role: "implementation")
46
52
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
47
53
  workspace = configured_workspace(root: conversation.workspace_root)
48
- tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
54
+ writer_id = worker_writer_id(role)
55
+ tool_registry = ToolRegistry.new(
56
+ workspace: workspace,
57
+ prompt: @prompt,
58
+ allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
59
+ write_lock: @worker_write_lock,
60
+ writer_id: writer_id
61
+ )
49
62
  @footer_conversation = conversation
50
63
  @footer_tool_registry = tool_registry
51
64
  Agent.new(
@@ -55,6 +68,34 @@ module Kward
55
68
  )
56
69
  end
57
70
 
71
+ def set_visible_worker(id, status: nil, worker: nil)
72
+ @visible_worker_id = id.to_s
73
+ @visible_worker_status = status
74
+ @visible_worker = worker
75
+ end
76
+
77
+ def worker_writer_id(role)
78
+ return nil unless Workers::ToolPolicy.write_capable?(role)
79
+
80
+ @worker_write_lock ||= Workers::WriteLock.new
81
+ owner_id = role.to_s.empty? ? "implementation" : role.to_s
82
+ return owner_id if @worker_write_lock.acquire(owner_id)
83
+
84
+ nil
85
+ end
86
+
87
+ def refresh_implementation_writer(agent)
88
+ return agent unless @active_worker_role == "implementation"
89
+ return agent unless agent&.respond_to?(:tool_registry)
90
+ return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
91
+
92
+ build_interactive_agent(agent.conversation)
93
+ end
94
+
95
+ def release_implementation_writer
96
+ @worker_write_lock&.release("implementation")
97
+ end
98
+
58
99
  def handle_interactive_shell_command(input, agent)
59
100
  command = input.to_s.sub(/\A!\s*/, "")
60
101
  if command.strip.empty?
@@ -73,6 +114,95 @@ module Kward
73
114
  input.to_s.start_with?("!")
74
115
  end
75
116
 
117
+ def run_ekwsh(agent)
118
+ unless @prompt.respond_to?(:ask)
119
+ runtime_output("The embedded shell is only available in interactive mode.")
120
+ return
121
+ end
122
+
123
+ tab = active_tab if respond_to?(:active_tab, true)
124
+ entering = tab.nil? || tab.shell.nil?
125
+ shell = tab&.shell || build_ekwsh(agent)
126
+ tab.shell = shell if tab
127
+ runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
128
+ run_ekwsh_loop(shell, tab: tab)
129
+ end
130
+
131
+ def build_ekwsh(agent)
132
+ config = ConfigFiles.read_ekwsh_config
133
+ Ekwsh.new(cwd: interactive_workspace_root(agent), configured_env: config[:env], aliases: config[:aliases])
134
+ end
135
+
136
+ def run_ekwsh_loop(shell, tab: nil)
137
+ loop do
138
+ if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
139
+ editor_result = @prompt.run_editor
140
+ if editor_result.is_a?(Hash) && editor_result[:tab_action]
141
+ (@pending_inputs ||= []).unshift(editor_result)
142
+ return :tab_action
143
+ end
144
+ end
145
+
146
+ input = ask_ekwsh(shell)
147
+ if input.is_a?(Hash) && input[:tab_action]
148
+ (@pending_inputs ||= []).unshift(input)
149
+ return :tab_action
150
+ end
151
+ break if input.nil?
152
+
153
+ result = run_ekwsh_command(shell, input)
154
+ @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
155
+ @prompt.say(result.output) unless result.output.to_s.empty?
156
+ if result.open_editor_path
157
+ editor_result = open_ekwsh_editor(result.open_editor_path, shell)
158
+ return :tab_action if editor_result == :tab_action
159
+
160
+ next
161
+ end
162
+ if result.exit_shell
163
+ tab.shell = nil if tab
164
+ runtime_output("Shell exited.")
165
+ return :exited
166
+ end
167
+ end
168
+ tab.shell = nil if tab
169
+ runtime_output("Shell exited.")
170
+ :exited
171
+ end
172
+
173
+ def open_ekwsh_editor(path, shell)
174
+ unless @prompt.respond_to?(:edit_file)
175
+ runtime_output("Integrated editor is unavailable in this prompt.")
176
+ return false
177
+ end
178
+
179
+ result = @prompt.edit_file(path, base_dir: shell.cwd, allow_new: true)
180
+ if result.is_a?(Hash) && result[:tab_action]
181
+ (@pending_inputs ||= []).unshift(result)
182
+ return :tab_action
183
+ end
184
+
185
+ result
186
+ end
187
+
188
+ def ask_ekwsh(shell)
189
+ provider = ->(input, cursor) { shell.complete(input, cursor) }
190
+ if @prompt.respond_to?(:with_completion_provider)
191
+ @prompt.with_completion_provider(provider) { @prompt.ask(shell.prompt_label) }
192
+ else
193
+ @prompt.ask(shell.prompt_label)
194
+ end
195
+ end
196
+
197
+ def run_ekwsh_command(shell, input)
198
+ if @prompt.respond_to?(:begin_busy_input)
199
+ @prompt.begin_busy_input(shell.prompt_label, activity: "running")
200
+ end
201
+ shell.run(input)
202
+ ensure
203
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
204
+ end
205
+
76
206
  def configured_workspace(root: current_workspace_root)
77
207
  Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
78
208
  end
@@ -146,10 +276,10 @@ module Kward
146
276
  @client.reload_config if @client.respond_to?(:reload_config)
147
277
  end
148
278
 
149
- def refresh_conversation_runtime(conversation)
279
+ def refresh_conversation_runtime(conversation, reasoning_effort: current_reasoning_effort, refresh_system_message: true)
150
280
  return unless conversation&.respond_to?(:update_runtime_context!)
151
281
 
152
- conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
282
+ conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: reasoning_effort, refresh: refresh_system_message)
153
283
  update_assistant_prompt(conversation)
154
284
  end
155
285
 
@@ -12,7 +12,7 @@ module Kward
12
12
  return @session_store if @session_store
13
13
  return nil if agent
14
14
 
15
- SessionStore.new
15
+ @session_store = SessionStore.new
16
16
  end
17
17
 
18
18
  def resume_last_session(session_store)
@@ -69,7 +69,7 @@ module Kward
69
69
  end
70
70
 
71
71
  def mutation_tool_call?(tool_call)
72
- ["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
72
+ ToolCall.file_change_tool?(ToolCall.name(tool_call))
73
73
  end
74
74
 
75
75
  def cleanup_unused_sessions
@@ -222,6 +222,21 @@ module Kward
222
222
  when /\Ashow busy help/, /\Ahide busy help/
223
223
  set_composer_busy_help(!composer_busy_help?)
224
224
  runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
225
+ when /\Atab keybindings/
226
+ configure_tab_keybindings
227
+ when /\Aeditor mode/
228
+ configure_editor_mode
229
+ when /\Aeditor line numbers/
230
+ configure_editor_line_numbers
231
+ when /\Aenable auto-close pairs/, /\Adisable auto-close pairs/
232
+ set_editor_auto_close_pairs_enabled(!editor_auto_close_pairs_enabled?)
233
+ runtime_output("Editor auto-close pairs #{editor_auto_close_pairs_enabled? ? "enabled" : "disabled"}.")
234
+ when /\Aenable soft-wrap/, /\Adisable soft-wrap/
235
+ set_editor_soft_wrap_enabled(!editor_soft_wrap_enabled?)
236
+ runtime_output("Editor soft-wrap #{editor_soft_wrap_enabled? ? "enabled" : "disabled"}.")
237
+ when /\Aenable bar cursor/, /\Adisable bar cursor/
238
+ set_editor_bar_cursor_enabled(!editor_bar_cursor_enabled?)
239
+ runtime_output("Editor bar cursor #{editor_bar_cursor_enabled? ? "enabled" : "disabled"}.")
225
240
  when /\Aenable session auto-resume/, /\Adisable session auto-resume/
226
241
  set_session_auto_resume_enabled(!session_auto_resume_enabled?)
227
242
  runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
@@ -234,6 +249,12 @@ module Kward
234
249
  "Overlay alignment (#{settings["alignment"]})",
235
250
  "Overlay width (#{settings["width"]})",
236
251
  "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
252
+ "Tab keybindings (#{composer_tab_keybindings})",
253
+ "Editor mode (#{editor_mode})",
254
+ "Editor line numbers (#{editor_line_numbers})",
255
+ "#{editor_auto_close_pairs_enabled? ? "Disable" : "Enable"} auto-close pairs (currently #{on_off(editor_auto_close_pairs_enabled?)})",
256
+ "#{editor_soft_wrap_enabled? ? "Disable" : "Enable"} soft-wrap (currently #{on_off(editor_soft_wrap_enabled?)})",
257
+ "#{editor_bar_cursor_enabled? ? "Disable" : "Enable"} bar cursor (currently #{on_off(editor_bar_cursor_enabled?)})",
237
258
  "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
238
259
  "Back"
239
260
  ]
@@ -243,6 +264,84 @@ module Kward
243
264
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
244
265
  end
245
266
 
267
+ def composer_tab_keybindings
268
+ ConfigFiles.composer_tab_keybindings(safely_read_config.to_h)
269
+ end
270
+
271
+ def configure_tab_keybindings
272
+ selected = @prompt.select("Tab keybindings", tab_keybinding_choices, title: "Settings")
273
+ value = selected.to_s.split.first.to_s.downcase
274
+ return unless %w[auto ctrl alt].include?(value)
275
+
276
+ update_nested_config("composer", "tab_keybindings" => value)
277
+ runtime_output("Tab keybindings set to #{value}. Restart the TUI to apply this setting.")
278
+ end
279
+
280
+ def tab_keybinding_choices
281
+ current = composer_tab_keybindings
282
+ %w[auto ctrl alt].map { |value| value == current ? "#{value} (current)" : value }
283
+ end
284
+
285
+ def editor_mode
286
+ ConfigFiles.editor_mode(safely_read_config.to_h)
287
+ end
288
+
289
+ def configure_editor_mode
290
+ selected = @prompt.select("Editor mode", editor_mode_choices, title: "Settings")
291
+ value = selected.to_s.split.first.to_s.downcase
292
+ return unless %w[modern emacs vibe].include?(value)
293
+
294
+ update_nested_config("editor", "mode" => value)
295
+ runtime_output("Editor mode set to #{value}. New editor buffers will use this mode.")
296
+ end
297
+
298
+ def editor_mode_choices
299
+ current = editor_mode
300
+ %w[modern emacs vibe].map { |value| value == current ? "#{value} (current)" : value }
301
+ end
302
+
303
+ def editor_line_numbers
304
+ ConfigFiles.editor_line_numbers(safely_read_config.to_h)
305
+ end
306
+
307
+ def configure_editor_line_numbers
308
+ selected = @prompt.select("Editor line numbers", editor_line_number_choices, title: "Settings")
309
+ value = selected.to_s.split.first.to_s.downcase
310
+ return unless %w[absolute relative].include?(value)
311
+
312
+ update_nested_config("editor", "line_numbers" => value)
313
+ runtime_output("Editor line numbers set to #{value}.")
314
+ end
315
+
316
+ def editor_line_number_choices
317
+ current = editor_line_numbers
318
+ %w[absolute relative].map { |value| value == current ? "#{value} (current)" : value }
319
+ end
320
+
321
+ def editor_auto_close_pairs_enabled?
322
+ ConfigFiles.editor_auto_close_pairs?(safely_read_config.to_h)
323
+ end
324
+
325
+ def set_editor_auto_close_pairs_enabled(enabled)
326
+ update_nested_config("editor", "auto_close_pairs" => enabled)
327
+ end
328
+
329
+ def editor_soft_wrap_enabled?
330
+ ConfigFiles.editor_soft_wrap?(safely_read_config.to_h)
331
+ end
332
+
333
+ def set_editor_soft_wrap_enabled(enabled)
334
+ update_nested_config("editor", "soft_wrap" => enabled)
335
+ end
336
+
337
+ def editor_bar_cursor_enabled?
338
+ ConfigFiles.editor_bar_cursor?(safely_read_config.to_h)
339
+ end
340
+
341
+ def set_editor_bar_cursor_enabled(enabled)
342
+ update_nested_config("editor", "bar_cursor" => enabled)
343
+ end
344
+
246
345
  def session_auto_resume_enabled?
247
346
  ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
248
347
  end
@@ -455,11 +554,7 @@ module Kward
455
554
  end
456
555
 
457
556
  def update_nested_config(section, values)
458
- config = ConfigFiles.read_config
459
- current = config[section].is_a?(Hash) ? config[section].dup : {}
460
- config[section] = current.merge(values)
461
- ConfigFiles.write_config(config)
462
- config
557
+ ConfigFiles.update_nested_config(section, values)
463
558
  end
464
559
 
465
560
  def on_off(value)
@@ -526,10 +621,7 @@ module Kward
526
621
  effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
527
622
  raise "Reasoning effort must be one of: #{choices.map(&:first).join(", ")}" unless effort
528
623
 
529
- ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
530
- reload_client_config
531
- refresh_conversation_runtime(conversation)
532
- @prompt.redraw if @prompt.respond_to?(:redraw)
624
+ set_reasoning_effort(effort, conversation, provider: provider)
533
625
  rescue StandardError => e
534
626
  runtime_output("Reasoning error: #{e.message}")
535
627
  end
@@ -609,6 +701,123 @@ module Kward
609
701
  end
610
702
  end
611
703
 
704
+ REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
705
+
706
+ def cycle_reasoning(conversation = current_footer_conversation, direction: :next, persist: :immediate)
707
+ provider = conversation&.provider || current_model_provider
708
+ model = conversation&.model || current_model_id
709
+ choices = ModelInfo.reasoning_effort_choices(provider, model)
710
+ return false if choices.empty?
711
+
712
+ current = (pending_reasoning_effort(provider) || conversation&.reasoning_effort || current_reasoning_effort).to_s
713
+ current_index = choices.index { |effort, _label| effort == current }
714
+ current_index ||= direction == :previous ? 0 : -1
715
+ offset = direction == :previous ? -1 : 1
716
+ effort = choices[(current_index + offset) % choices.length].first
717
+ persist == :debounced ? apply_reasoning_effort(effort, conversation, provider: provider) : set_reasoning_effort(effort, conversation, provider: provider)
718
+ true
719
+ rescue StandardError => e
720
+ runtime_output("Reasoning error: #{e.message}")
721
+ false
722
+ end
723
+
724
+ def set_reasoning_effort(effort, conversation = nil, provider: nil)
725
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = nil }
726
+ persist_reasoning_config(effort, provider: provider)
727
+ apply_reasoning_effort(effort, conversation, provider: provider, queue_config: false)
728
+ end
729
+
730
+ def apply_reasoning_effort(effort, conversation = nil, provider: nil, queue_config: true)
731
+ queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
732
+ if queue_config
733
+ update_conversation_reasoning_effort(conversation, effort)
734
+ refresh_reasoning_status
735
+ else
736
+ refresh_conversation_runtime(conversation, reasoning_effort: effort)
737
+ conversation.persist_runtime_context! if conversation&.respond_to?(:persist_runtime_context!)
738
+ @prompt.redraw if @prompt.respond_to?(:redraw)
739
+ end
740
+ end
741
+
742
+ def refresh_reasoning_status
743
+ if @prompt.respond_to?(:refresh_composer_status)
744
+ @prompt.refresh_composer_status
745
+ else
746
+ @prompt.redraw if @prompt.respond_to?(:redraw)
747
+ end
748
+ end
749
+
750
+ def update_conversation_reasoning_effort(conversation, effort)
751
+ return unless conversation&.respond_to?(:update_runtime_context!)
752
+
753
+ conversation.update_runtime_context!(
754
+ provider: conversation.provider || current_model_provider,
755
+ model: conversation.model || current_model_id,
756
+ reasoning_effort: effort,
757
+ refresh: false
758
+ )
759
+ end
760
+
761
+ def pending_reasoning_effort(provider)
762
+ @pending_reasoning_config_mutex.synchronize do
763
+ pending = @pending_reasoning_config
764
+ return nil unless pending
765
+ return nil unless pending[:provider].to_s.downcase == provider.to_s.downcase
766
+
767
+ pending[:effort]
768
+ end
769
+ end
770
+
771
+ def queue_reasoning_config(effort, provider: nil, conversation: nil)
772
+ pending = {
773
+ effort: effort,
774
+ provider: provider || current_model_provider,
775
+ conversation: conversation,
776
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + REASONING_CONFIG_DEBOUNCE_SECONDS
777
+ }
778
+ @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = pending }
779
+ schedule_reasoning_config_flush
780
+ end
781
+
782
+ def schedule_reasoning_config_flush
783
+ return if @pending_reasoning_config_thread&.alive?
784
+
785
+ @pending_reasoning_config_thread = Thread.new do
786
+ loop do
787
+ sleep REASONING_CONFIG_DEBOUNCE_SECONDS
788
+ break if flush_pending_reasoning_config(force: false)
789
+ break unless @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config }
790
+ end
791
+ rescue StandardError => e
792
+ runtime_output("Reasoning error: #{e.message}")
793
+ end
794
+ end
795
+
796
+ def flush_pending_reasoning_config(force: true, conversation: nil)
797
+ pending = nil
798
+ @pending_reasoning_config_mutex.synchronize do
799
+ pending = @pending_reasoning_config
800
+ return false unless pending
801
+
802
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
803
+ return false if !force && now < pending[:deadline].to_f
804
+
805
+ @pending_reasoning_config = nil
806
+ end
807
+ persist_reasoning_config(pending[:effort], provider: pending[:provider])
808
+ conversation ||= pending[:conversation]
809
+ if conversation&.reasoning_effort.to_s == pending[:effort].to_s
810
+ refresh_conversation_runtime(conversation, reasoning_effort: pending[:effort])
811
+ conversation.persist_runtime_context! if conversation.respond_to?(:persist_runtime_context!)
812
+ end
813
+ true
814
+ end
815
+
816
+ def persist_reasoning_config(effort, provider: nil)
817
+ ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(provider || current_model_provider) => effort)
818
+ reload_client_config
819
+ end
820
+
612
821
  def reasoning_choices(choices, conversation = current_footer_conversation)
613
822
  current = (conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)).to_s
614
823
  choices.map do |effort, label|