kward 0.84.0 → 0.85.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -1,1061 +1,12 @@
1
- # Namespace for the Kward CLI agent runtime.
1
+ require_relative "settings/menus"
2
+ require_relative "settings/model"
3
+
4
+ # CLI settings facade retaining the original include surface.
2
5
  module Kward
3
- # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
6
  class CLI
5
- # Interactive settings menu actions mixed into the CLI frontend.
6
7
  module Settings
7
- private
8
-
9
- def configure_settings(conversation = nil)
10
- unless settings_overlay_available?
11
- runtime_output("Settings overlay is unavailable in this prompt.")
12
- return
13
- end
14
-
15
- initial_index = 0
16
- loop do
17
- choices = settings_category_choices
18
- selected, selected_index = select_settings_menu_item("Settings category", choices, initial_index)
19
- category = selected_settings_category(selected)
20
- break unless category
21
-
22
- initial_index = selected_index if selected_index
23
- break if category == "done"
24
-
25
- handle_settings_category(category, conversation)
26
- end
27
- rescue StandardError => e
28
- runtime_output("Settings error: #{e.message}")
29
- end
30
-
31
- # Returns the category labels shown by the interactive settings overlay.
32
- def settings_category_choices
33
- [
34
- "Model & Reasoning",
35
- "Accounts",
36
- "Memory",
37
- "Interface",
38
- "Tools & Search",
39
- "Context & Compaction",
40
- "Personalization",
41
- "Logging",
42
- "Advanced",
43
- "Done"
44
- ]
45
- end
46
-
47
- # Maps a selected settings label back to the internal category key.
48
- def selected_settings_category(selected)
49
- text = selected.to_s.downcase
50
- return nil if text.empty?
51
- return "done" if text.start_with?("done")
52
- return "model" if text.start_with?("model")
53
- return "accounts" if text.start_with?("accounts")
54
- return "memory" if text.start_with?("memory")
55
- return "interface" if text.start_with?("interface")
56
- return "tools" if text.start_with?("tools")
57
- return "context" if text.start_with?("context")
58
- return "personalization" if text.start_with?("personalization")
59
- return "logging" if text.start_with?("logging")
60
- return "advanced" if text.start_with?("advanced")
61
-
62
- nil
63
- end
64
-
65
- def handle_settings_category(category, conversation)
66
- case category
67
- when "model"
68
- configure_model_settings(conversation)
69
- when "accounts"
70
- configure_account_settings
71
- when "memory"
72
- configure_memory_settings(conversation)
73
- when "interface"
74
- configure_interface_settings
75
- when "tools"
76
- configure_tools_settings
77
- when "context"
78
- configure_context_settings
79
- when "personalization"
80
- configure_personalization_settings(conversation)
81
- when "logging"
82
- configure_logging_settings
83
- when "advanced"
84
- show_advanced_settings
85
- end
86
- end
87
-
88
- def configure_model_settings(conversation)
89
- initial_index = 0
90
- loop do
91
- selected, selected_index = select_settings_menu_item("Model & Reasoning", ["Provider", "Default model", "Local server preset", "Reasoning effort", "Back"], initial_index)
92
- break unless selected
93
-
94
- initial_index = selected_index if selected_index
95
- case selected.to_s.downcase
96
- when /\Aprovider/
97
- configure_provider(conversation)
98
- when /\Adefault model/
99
- configure_model(conversation)
100
- when /\Alocal server preset/
101
- configure_local_server_preset(conversation)
102
- when /\Areasoning effort/
103
- configure_reasoning(conversation)
104
- else
105
- break
106
- end
107
- end
108
- end
109
-
110
- def configure_provider(conversation)
111
- selected = @prompt.select("Provider", provider_choices, title: "Settings")
112
- provider = selected_provider(selected)
113
- return unless provider
114
-
115
- ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
116
- reload_client_config
117
- refresh_conversation_runtime(conversation)
118
- refresh_composer_status
119
- end
120
-
121
- def provider_choices
122
- current = current_model_provider
123
- configured_api_key_providers = ProviderCatalog.api_key_providers.filter_map do |provider|
124
- provider.name if api_key_store.configured?(provider.id)
125
- end
126
- (["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"] + configured_api_key_providers).uniq.map do |provider|
127
- label = provider.dup
128
- label += " (current)" if provider == current
129
- label
130
- end
131
- end
132
-
133
- def selected_provider(selected)
134
- text = selected.to_s.downcase
135
- return "Codex" if text.start_with?("codex")
136
- return "Anthropic" if text.start_with?("anthropic") || text.start_with?("claude")
137
- return "OpenRouter" if text.start_with?("openrouter")
138
- return "Copilot" if text.start_with?("copilot")
139
- return "Local" if text.start_with?("local")
140
-
141
- ProviderCatalog.find_by_name(selected)&.name
142
- end
143
-
144
- def configure_local_server_preset(conversation)
145
- choices = [
146
- "Ollama (http://127.0.0.1:11434/v1)",
147
- "LM Studio (http://127.0.0.1:1234/v1)",
148
- "llama.cpp (http://127.0.0.1:8080/v1)",
149
- "Custom endpoint (edit config)",
150
- "Back"
151
- ]
152
- selected = @prompt.select("Local server preset", choices, title: "Settings")
153
- return unless selected
154
-
155
- backend, url = case selected.to_s
156
- when /\AOllama/ then ["ollama", "http://127.0.0.1:11434/v1"]
157
- when /\ALM Studio/ then ["lm_studio", "http://127.0.0.1:1234/v1"]
158
- when /\Allama\.cpp/ then ["llama_cpp", "http://127.0.0.1:8080/v1"]
159
- when /\ACustom endpoint/
160
- runtime_output("Set local_base_url in #{ConfigFiles.config_path}.")
161
- return
162
- else
163
- return
164
- end
165
-
166
- ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
167
- reload_client_config
168
- refresh_conversation_runtime(conversation)
169
- refresh_composer_status
170
- end
171
-
172
- def configure_account_settings
173
- initial_index = 0
174
- loop do
175
- selected, selected_index = select_settings_menu_item("Accounts", account_setting_choices, initial_index)
176
- break unless selected
177
-
178
- initial_index = selected_index if selected_index
179
- case selected.to_s.downcase
180
- when /\Aopenai/
181
- login(provider: "openai")
182
- reload_client_config
183
- when /\Aanthropic/, /\Aclaude/
184
- login(provider: "anthropic")
185
- reload_client_config
186
- when /\Agithub/
187
- login(provider: "github")
188
- reload_client_config
189
- when /\Aopenrouter/
190
- login(provider: "openrouter")
191
- reload_client_config
192
- when /\Astatus/
193
- print_auth_status
194
- else
195
- break
196
- end
197
- end
198
- end
199
-
200
- def account_setting_choices
201
- config = safely_read_config.to_h
202
- [
203
- "OpenAI login (#{File.exist?(OpenAIOAuth.default_auth_path) ? "configured" : "not configured"})",
204
- "Anthropic login (#{File.exist?(AnthropicOAuth.default_auth_path) ? "configured" : "not configured"})",
205
- "GitHub login (#{File.exist?(GithubOAuth.default_auth_path) ? "configured" : "not configured"})",
206
- "OpenRouter API key (#{openrouter_key_status(config)})",
207
- "Status",
208
- "Back"
209
- ]
210
- end
211
-
212
- def openrouter_key_status(config)
213
- return "configured via environment" unless ENV["OPENROUTER_API_KEY"].to_s.empty?
214
-
215
- config["openrouter_api_key"].to_s.empty? ? "not configured" : "configured"
216
- end
217
-
218
- def configure_memory_settings(conversation)
219
- initial_index = 0
220
- loop do
221
- selected, selected_index = select_settings_menu_item("Memory", memory_setting_choices, initial_index)
222
- break unless selected
223
-
224
- initial_index = selected_index if selected_index
225
- case selected.to_s.downcase
226
- when /\Aenable memory/, /\Adisable memory/
227
- set_config_flag("memory", "enabled", !memory_enabled?)
228
- conversation&.refresh_system_message!
229
- runtime_output("Memory #{memory_enabled? ? "enabled" : "disabled"}.")
230
- when /\Aenable auto-summary/, /\Adisable auto-summary/
231
- set_config_flag("memory", "auto_summary", !memory_auto_summary_enabled?)
232
- runtime_output("Memory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.")
233
- when /\Amanage/
234
- runtime_output("Use /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.")
235
- else
236
- break
237
- end
238
- end
239
- end
240
-
241
- def memory_setting_choices
242
- [
243
- "#{memory_enabled? ? "Disable" : "Enable"} memory (currently #{on_off(memory_enabled?)})",
244
- "#{memory_auto_summary_enabled? ? "Disable" : "Enable"} auto-summary (currently #{on_off(memory_auto_summary_enabled?)})",
245
- "Manage memories with /memory",
246
- "Back"
247
- ]
248
- end
249
-
250
- def memory_enabled?
251
- memory = safely_read_config.to_h["memory"]
252
- memory.is_a?(Hash) && memory["enabled"] == true
253
- end
254
-
255
- def memory_auto_summary_enabled?
256
- memory = safely_read_config.to_h["memory"]
257
- memory.is_a?(Hash) && memory["auto_summary"] == true
258
- end
259
-
260
- def configure_interface_settings
261
- initial_index = 0
262
- loop do
263
- selected, selected_index = select_settings_menu_item("Interface", interface_setting_choices, initial_index)
264
- break unless selected
265
-
266
- initial_index = selected_index if selected_index
267
- case selected.to_s.downcase
268
- when /\Aoverlay alignment/
269
- settings = ConfigFiles.overlay_settings
270
- alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
271
- next unless alignment
272
-
273
- update_overlay_settings("alignment" => alignment)
274
- when /\Aoverlay width/
275
- settings = ConfigFiles.overlay_settings
276
- width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
277
- next unless width
278
-
279
- update_overlay_settings("width" => width)
280
- when /\Afile icons/
281
- configure_project_browser_icon_theme
282
- when /\Ashow busy help/, /\Ahide busy help/
283
- set_composer_busy_help(!composer_busy_help?)
284
- runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
285
- when /\Atab keybindings/
286
- configure_tab_keybindings
287
- when /\Aeditor mode/
288
- configure_editor_mode
289
- when /\Aeditor line numbers/
290
- configure_editor_line_numbers
291
- when /\Adiff view/
292
- configure_diff_view
293
- when /\Aenable auto-close pairs/, /\Adisable auto-close pairs/
294
- set_config_flag("editor", "auto_close_pairs", !editor_auto_close_pairs_enabled?)
295
- runtime_output("Editor auto-close pairs #{editor_auto_close_pairs_enabled? ? "enabled" : "disabled"}.")
296
- when /\Aenable soft-wrap/, /\Adisable soft-wrap/
297
- set_config_flag("editor", "soft_wrap", !editor_soft_wrap_enabled?)
298
- runtime_output("Editor soft-wrap #{editor_soft_wrap_enabled? ? "enabled" : "disabled"}.")
299
- when /\Aenable bar cursor/, /\Adisable bar cursor/
300
- set_config_flag("editor", "bar_cursor", !editor_bar_cursor_enabled?)
301
- runtime_output("Editor bar cursor #{editor_bar_cursor_enabled? ? "enabled" : "disabled"}.")
302
- when /\Aenable session auto-resume/, /\Adisable session auto-resume/
303
- set_config_flag("sessions", "auto_resume", !session_auto_resume_enabled?)
304
- runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
305
- else
306
- break
307
- end
308
- end
309
- end
310
-
311
- def interface_setting_choices
312
- settings = ConfigFiles.overlay_settings
313
- [
314
- "Overlay alignment (#{settings["alignment"]})",
315
- "Overlay width (#{settings["width"]})",
316
- "File icons (#{project_browser_icon_theme})",
317
- "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
318
- "Tab keybindings (#{composer_tab_keybindings})",
319
- "Editor mode (#{editor_mode})",
320
- "Editor line numbers (#{editor_line_numbers})",
321
- "Diff view (#{diff_view_label(diff_view)})",
322
- "#{editor_auto_close_pairs_enabled? ? "Disable" : "Enable"} auto-close pairs (currently #{on_off(editor_auto_close_pairs_enabled?)})",
323
- "#{editor_soft_wrap_enabled? ? "Disable" : "Enable"} soft-wrap (currently #{on_off(editor_soft_wrap_enabled?)})",
324
- "#{editor_bar_cursor_enabled? ? "Disable" : "Enable"} bar cursor (currently #{on_off(editor_bar_cursor_enabled?)})",
325
- "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
326
- "Back"
327
- ]
328
- end
329
-
330
- def project_browser_icon_theme
331
- ConfigFiles.project_browser_icon_theme(safely_read_config.to_h)
332
- end
333
-
334
- def configure_project_browser_icon_theme
335
- selected = @prompt.select("File icons", project_browser_icon_theme_choices, title: "Settings")
336
- theme = selected.to_s.split.first.to_s.downcase
337
- return unless ConfigFiles::PROJECT_BROWSER_ICON_THEMES.include?(theme)
338
-
339
- update_nested_config("project_browser", "icons" => theme)
340
- @prompt.update_project_browser_icon_theme(theme) if @prompt.respond_to?(:update_project_browser_icon_theme)
341
- runtime_output("File icons set to #{theme}.")
342
- end
343
-
344
- def project_browser_icon_theme_choices
345
- current = project_browser_icon_theme
346
- ConfigFiles::PROJECT_BROWSER_ICON_THEMES.map { |theme| theme == current ? "#{theme} (current)" : theme }
347
- end
348
-
349
- def composer_busy_help?
350
- ConfigFiles.composer_busy_help?(safely_read_config.to_h)
351
- end
352
-
353
- def composer_tab_keybindings
354
- ConfigFiles.composer_tab_keybindings(safely_read_config.to_h)
355
- end
356
-
357
- def configure_tab_keybindings
358
- selected = @prompt.select("Tab keybindings", tab_keybinding_choices, title: "Settings")
359
- value = selected.to_s.split.first.to_s.downcase
360
- return unless %w[auto ctrl alt].include?(value)
361
-
362
- update_nested_config("composer", "tab_keybindings" => value)
363
- runtime_output("Tab keybindings set to #{value}. Restart the TUI to apply this setting.")
364
- end
365
-
366
- def tab_keybinding_choices
367
- current = composer_tab_keybindings
368
- %w[auto ctrl alt].map { |value| value == current ? "#{value} (current)" : value }
369
- end
370
-
371
- def editor_mode
372
- ConfigFiles.editor_mode(safely_read_config.to_h)
373
- end
374
-
375
- def configure_editor_mode
376
- selected = @prompt.select("Editor mode", editor_mode_choices, title: "Settings")
377
- value = selected.to_s.split.first.to_s.downcase
378
- return unless %w[modern emacs vibe].include?(value)
379
-
380
- update_nested_config("editor", "mode" => value)
381
- runtime_output("Editor mode set to #{value}. New editor buffers will use this mode.")
382
- end
383
-
384
- def editor_mode_choices
385
- current = editor_mode
386
- %w[modern emacs vibe].map { |value| value == current ? "#{value} (current)" : value }
387
- end
388
-
389
- def editor_line_numbers
390
- ConfigFiles.editor_line_numbers(safely_read_config.to_h)
391
- end
392
-
393
- def configure_editor_line_numbers
394
- selected = @prompt.select("Editor line numbers", editor_line_number_choices, title: "Settings")
395
- value = selected.to_s.split.first.to_s.downcase
396
- return unless %w[absolute relative].include?(value)
397
-
398
- update_nested_config("editor", "line_numbers" => value)
399
- runtime_output("Editor line numbers set to #{value}.")
400
- end
401
-
402
- def editor_line_number_choices
403
- current = editor_line_numbers
404
- %w[absolute relative].map { |value| value == current ? "#{value} (current)" : value }
405
- end
406
-
407
- def diff_view
408
- ConfigFiles.diff_view(safely_read_config.to_h)
409
- end
410
-
411
- def configure_diff_view
412
- selected = @prompt.select("Diff view", diff_view_choices, title: "Settings")
413
- value = selected.to_s.split.first.to_s.downcase.tr("-", "_")
414
- return unless Kward::DiffViewMode::MODES.include?(value)
415
-
416
- update_nested_config("editor", "diff_view" => value)
417
- runtime_output("Diff view set to #{diff_view_label(value)}.")
418
- end
419
-
420
- def diff_view_choices
421
- current = diff_view
422
- Kward::DiffViewMode::MODES.map do |value|
423
- label = diff_view_label(value)
424
- value == current ? "#{label} (current)" : label
425
- end
426
- end
427
-
428
- def diff_view_label(value)
429
- Kward::DiffViewMode.label(value)
430
- end
431
-
432
- def editor_auto_close_pairs_enabled?
433
- ConfigFiles.editor_auto_close_pairs?(safely_read_config.to_h)
434
- end
435
-
436
- def editor_soft_wrap_enabled?
437
- ConfigFiles.editor_soft_wrap?(safely_read_config.to_h)
438
- end
439
-
440
- def editor_bar_cursor_enabled?
441
- ConfigFiles.editor_bar_cursor?(safely_read_config.to_h)
442
- end
443
-
444
- def session_auto_resume_enabled?
445
- ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
446
- end
447
-
448
- def set_composer_busy_help(enabled)
449
- set_config_flag("composer", "busy_help", enabled)
450
- end
451
-
452
- def configure_tools_settings
453
- initial_index = 0
454
- loop do
455
- selected, selected_index = select_settings_menu_item("Tools & Search", tools_setting_choices, initial_index)
456
- break unless selected
457
-
458
- initial_index = selected_index if selected_index
459
- case selected.to_s.downcase
460
- when /\Aenable web search/, /\Adisable web search/
461
- set_config_flag("web_search", "enabled", !web_search_enabled?)
462
- runtime_output("Web search #{web_search_enabled? ? "enabled" : "disabled"}.")
463
- when /\Aweb search provider/
464
- configure_web_search_provider
465
- when /\Aallow model-provider/, /\Adisallow model-provider/
466
- set_config_flag("web_search", "allow_model_providers", !web_search_allow_model_providers?)
467
- runtime_output("Model-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.")
468
- when /\Atrust project skills/, /\Auntrust project skills/
469
- set_config_flag("skills", "trust_project", !project_skills_trusted?)
470
- runtime_output("Project skills #{project_skills_trusted? ? "trusted" : "untrusted"}.")
471
- when /\Aenable workspace guardrails/, /\Adisable workspace guardrails/
472
- set_config_flag("tools", "workspace_guardrails", !workspace_guardrails_enabled?)
473
- runtime_output("Workspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.")
474
- else
475
- break
476
- end
477
- end
478
- end
479
-
480
- def tools_setting_choices
481
- [
482
- "#{web_search_enabled? ? "Disable" : "Enable"} web search (currently #{on_off(web_search_enabled?)})",
483
- "Web search provider (#{web_search_provider})",
484
- "#{web_search_allow_model_providers? ? "Disallow" : "Allow"} model-provider web search (currently #{on_off(web_search_allow_model_providers?)})",
485
- "#{project_skills_trusted? ? "Untrust" : "Trust"} project skills (currently #{on_off(project_skills_trusted?)})",
486
- "#{workspace_guardrails_enabled? ? "Disable" : "Enable"} workspace guardrails (currently #{on_off(workspace_guardrails_enabled?)})",
487
- "Back"
488
- ]
489
- end
490
-
491
- def configure_web_search_provider
492
- providers = WebSearch::PROVIDERS
493
- selected = @prompt.select("Web search provider", providers.map { |provider| provider == web_search_provider ? "#{provider} (current)" : provider }, title: "Settings")
494
- provider = providers.find { |value| selected.to_s.downcase.start_with?(value) }
495
- return unless provider
496
-
497
- update_nested_config("web_search", "provider" => provider)
498
- end
499
-
500
- def web_search_config
501
- ConfigFiles.web_search_config(safely_read_config.to_h)
502
- end
503
-
504
- def web_search_enabled?
505
- web_search_config["enabled"] != false
506
- end
507
-
508
- def web_search_provider
509
- web_search_config["provider"].to_s.empty? ? "auto" : web_search_config["provider"].to_s
510
- end
511
-
512
- def web_search_allow_model_providers?
513
- web_search_config["allow_model_providers"] == true
514
- end
515
-
516
- def project_skills_trusted?
517
- ConfigFiles.project_skills_trusted?(safely_read_config.to_h)
518
- end
519
-
520
- def configure_context_settings
521
- initial_index = 0
522
- loop do
523
- selected, selected_index = select_settings_menu_item("Context & Compaction", context_setting_choices, initial_index)
524
- break unless selected
525
-
526
- initial_index = selected_index if selected_index
527
- case selected.to_s.downcase
528
- when /\Aenable auto-compaction/, /\Adisable auto-compaction/
529
- set_config_flag("compaction", "enabled", !compaction_enabled?)
530
- runtime_output("Auto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.")
531
- when /\Astatus/
532
- runtime_output(auto_compaction_status_line)
533
- else
534
- break
535
- end
536
- end
537
- end
538
-
539
- def context_setting_choices
540
- [
541
- "#{compaction_enabled? ? "Disable" : "Enable"} auto-compaction (currently #{on_off(compaction_enabled?)})",
542
- "Status",
543
- "Back"
544
- ]
545
- end
546
-
547
- def compaction_enabled?
548
- Kward::Compaction::Settings.from_config(safely_read_config.to_h).enabled
549
- end
550
-
551
- def configure_personalization_settings(conversation)
552
- initial_index = 0
553
- loop do
554
- selected, selected_index = select_settings_menu_item("Personalization", personalization_setting_choices(conversation), initial_index)
555
- break unless selected
556
-
557
- initial_index = selected_index if selected_index
558
- case selected.to_s.downcase
559
- when /\Adefault persona/
560
- configure_default_persona(conversation)
561
- when /\Aenable global principles/, /\Adisable global principles/
562
- toggle_global_principles(conversation)
563
- when /\Aglobal principles \(ignored/
564
- runtime_output("Global principles are ignored while a replacement system prompt is configured.")
565
- when /\Aactive instructions/
566
- show_active_instructions_summary(conversation)
567
- else
568
- break
569
- end
570
- end
571
- end
572
-
573
- def personalization_setting_choices(conversation)
574
- config = safely_read_config.to_h
575
- replacement = ConfigFiles.system_prompt_file_path(config)
576
- principles = if replacement
577
- "Global principles (ignored by replacement)"
578
- elsif ConfigFiles.include_config_principles?(config)
579
- "Disable global principles"
580
- else
581
- "Enable global principles"
582
- end
583
- [
584
- "Default persona (#{default_persona_label})",
585
- principles,
586
- "Active instructions summary",
587
- "Back"
588
- ]
589
- end
590
-
591
- def toggle_global_principles(conversation)
592
- config = safely_read_config.to_h
593
- settings = config["system_prompt"].is_a?(Hash) ? config["system_prompt"] : {}
594
- ConfigFiles.update_config("system_prompt" => settings.merge("include_principles" => !ConfigFiles.include_config_principles?(config)))
595
- conversation&.refresh_system_message!
596
- @prompt.redraw if @prompt.respond_to?(:redraw)
597
- end
598
-
599
- def default_persona_label
600
- personas = safely_read_config.to_h["personas"]
601
- value = personas.is_a?(Hash) ? personas["default"] : nil
602
- value.to_s.empty? ? "none" : value.to_s
603
- end
604
-
605
- def configure_default_persona(conversation)
606
- config = safely_read_config.to_h
607
- personas = config["personas"].is_a?(Hash) ? config["personas"] : {}
608
- entries = ConfigFiles.crew_character_labels(personas)
609
- choices = entries.map { |key, label| key == personas["default"] ? "#{label} (#{key}, current)" : "#{label} (#{key})" }
610
- if choices.empty?
611
- runtime_output("No configured personas found. Edit #{ConfigFiles.config_path} to add personas.")
612
- return
613
- end
614
-
615
- selected = @prompt.select("Default persona", choices, title: "Settings")
616
- key = entries.keys.find { |candidate| selected.to_s.include?("(#{candidate}") }
617
- return unless key
618
-
619
- personas = personas.dup
620
- personas["default"] = key
621
- ConfigFiles.update_config("personas" => personas)
622
- conversation&.refresh_system_message!
623
- @prompt.redraw if @prompt.respond_to?(:redraw)
624
- end
625
-
626
- def show_active_instructions_summary(conversation)
627
- label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
628
- config = safely_read_config.to_h
629
- replacement_path = ConfigFiles.system_prompt_file_path(config)
630
- lines = ["Active persona: #{label || "none"}"]
631
- if replacement_path
632
- lines << "System prompt: replacement (#{replacement_path})"
633
- lines << "Global PRINCIPLES.md: ignored by replacement"
634
- lines << "Workspace AGENTS.md: ignored by replacement"
635
- else
636
- lines << "System prompt: Kward default"
637
- lines << "Global PRINCIPLES.md: #{ConfigFiles.include_config_principles?(config) ? (ConfigFiles.agents_prompt ? "present" : "absent") : "disabled"}"
638
- lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
639
- end
640
- lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
641
- runtime_output(lines.join("\n"))
642
- end
643
-
644
- def configure_logging_settings
645
- initial_index = 0
646
- loop do
647
- selected, selected_index = select_settings_menu_item("Logging", logging_setting_choices, initial_index)
648
- break unless selected
649
-
650
- initial_index = selected_index if selected_index
651
- key = logging_key_for_choice(selected)
652
- break unless key
653
-
654
- set_logging_value(key, !logging_enabled?(key))
655
- runtime_output("Logging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.")
656
- end
657
- end
658
-
659
- def logging_setting_choices
660
- [
661
- "#{logging_enabled?("enabled") ? "Disable" : "Enable"} local logging (currently #{on_off(logging_enabled?("enabled"))})",
662
- "#{logging_enabled?("tokens") ? "Disable" : "Enable"} token logs (currently #{on_off(logging_enabled?("tokens"))})",
663
- "#{logging_enabled?("performance") ? "Disable" : "Enable"} performance logs (currently #{on_off(logging_enabled?("performance"))})",
664
- "#{logging_enabled?("tools") ? "Disable" : "Enable"} tool logs (currently #{on_off(logging_enabled?("tools"))})",
665
- "#{logging_enabled?("errors") ? "Disable" : "Enable"} error logs (currently #{on_off(logging_enabled?("errors"))})",
666
- "Back"
667
- ]
668
- end
669
-
670
- def logging_key_for_choice(selected)
671
- text = selected.to_s.downcase
672
- return "enabled" if text.include?("local logging")
673
- return "tokens" if text.include?("token logs")
674
- return "performance" if text.include?("performance logs")
675
- return "tools" if text.include?("tool logs")
676
- return "errors" if text.include?("error logs")
677
-
678
- nil
679
- end
680
-
681
- def logging_enabled?(key)
682
- logging = safely_read_config.to_h["logging"]
683
- logging.is_a?(Hash) && logging[key] == true
684
- end
685
-
686
- def set_logging_value(key, value)
687
- set_config_flag("logging", key, value)
688
- end
689
-
690
- def show_advanced_settings
691
- lines = [
692
- "Config path: #{ConfigFiles.config_path}",
693
- "Config directory: #{ConfigFiles.config_dir}",
694
- "Cache directory: #{ConfigFiles.cache_dir}",
695
- "Memory directory: #{ConfigFiles.memory_dir}",
696
- "Plugin directory: #{ConfigFiles.plugin_dir}",
697
- "Plugins: #{ConfigFiles.plugin_paths.length}",
698
- "Skills: #{ConfigFiles.skills.length}",
699
- "Prompt templates: #{ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).length}"
700
- ]
701
- runtime_output(lines.join("\n"))
702
- end
703
-
704
- def update_nested_config(section, values)
705
- ConfigFiles.update_nested_config(section, values)
706
- end
707
-
708
- def set_config_flag(section, key, enabled)
709
- update_nested_config(section, key => enabled)
710
- end
711
-
712
- def select_settings_menu_item(message, choices, initial_index)
713
- selected = @prompt.select(message, choices, title: "Settings", initial_index: initial_index)
714
- [selected, choices.index(selected)]
715
- end
716
-
717
- def on_off(value)
718
- value ? "on" : "off"
719
- end
720
-
721
- def login_interactively
722
- unless login_picker_available?
723
- runtime_output("Login provider picker is unavailable in this prompt.")
724
- return
725
- end
726
-
727
- method = selected_login_method(@prompt.select("How would you like to connect?", login_method_choices, title: "Login"))
728
- return unless method
729
-
730
- selected = @prompt.select(login_provider_prompt(method), login_provider_choices(method), title: "Login")
731
- provider = selected_login_provider(selected)
732
- return unless provider
733
-
734
- run_busy_local_command_and_requeue(activity: "running") do
735
- login(provider: provider, auth_method: method)
736
- reload_client_config
737
- end
738
- rescue StandardError => e
739
- runtime_output("Login error: #{e.message}")
740
- end
741
-
742
- def configure_model(conversation = nil, models: nil)
743
- unless model_overlay_available?
744
- runtime_output("Model overlay is unavailable in this prompt.")
745
- return
746
- end
747
-
748
- models ||= normalized_available_models(conversation)
749
- provider_filter = nil
750
- show_all = false
751
- loop do
752
- visible_models = provider_filter ? models.select { |model| model[:provider] == provider_filter } : models
753
- visible_models = visible_models.select { |model| default_selectable_model?(model) } unless show_all
754
- actions = ["Refresh model list", "Enter model ID manually", "Show all models", "Change provider"]
755
- selected = @prompt.select("Default model", model_choices(visible_models, conversation) + actions, title: "Models", custom: true)
756
- return unless selected
757
-
758
- case selected.to_s
759
- when "Refresh model list"
760
- refreshed = @client.refresh_available_models(provider: provider_filter) if @client.respond_to?(:refresh_available_models)
761
- models = normalized_available_models(conversation, models: refreshed)
762
- when "Enter model ID manually"
763
- provider = provider_filter || conversation&.provider || current_model_provider
764
- model = @prompt.select("Model ID for #{provider}", [], title: "Models", custom: true)
765
- return unless model
766
- persist_model_selection(provider, model, conversation)
767
- return
768
- when "Show all models"
769
- provider_filter = nil
770
- show_all = true
771
- when "Change provider"
772
- selected_provider_name = selected_provider(@prompt.select("Provider", provider_choices, title: "Models"))
773
- if selected_provider_name
774
- provider_filter = selected_provider_name
775
- show_all = false
776
- end
777
- else
778
- provider, model = selected_model(selected, models)
779
- persist_model_selection(provider, model, conversation)
780
- return
781
- end
782
- end
783
- rescue StandardError => e
784
- runtime_output("Model error: #{e.message}")
785
- end
786
-
787
- def configure_reasoning(conversation = nil)
788
- unless model_overlay_available?
789
- runtime_output("Reasoning overlay is unavailable in this prompt.")
790
- return
791
- end
792
-
793
- provider = conversation&.provider || current_model_provider
794
- model = conversation&.model || current_model_id
795
- choices = ModelInfo.reasoning_effort_choices(provider, model)
796
- if choices.empty?
797
- runtime_output("Reasoning effort is unavailable for #{provider} #{model}.")
798
- return
799
- end
800
-
801
- selected = @prompt.select("Reasoning effort", reasoning_choices(choices, conversation), title: "Reasoning")
802
- return unless selected
803
-
804
- effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
805
- raise "Reasoning effort must be one of: #{choices.map(&:first).join(", ")}" unless effort
806
-
807
- set_reasoning_effort(effort, conversation, provider: provider)
808
- rescue StandardError => e
809
- runtime_output("Reasoning error: #{e.message}")
810
- end
811
-
812
- def login_picker_available?
813
- @prompt.respond_to?(:select)
814
- end
815
-
816
- def login_method_choices
817
- ["API key", "Subscription / OAuth"]
818
- end
819
-
820
- def selected_login_method(selected)
821
- case selected.to_s.downcase
822
- when /\Aapi key\z/ then :api_key
823
- when /\Asubscription \/ oauth\z/ then :oauth
824
- end
825
- end
826
-
827
- def login_provider_prompt(method)
828
- method == :api_key ? "Add an API key" : "Sign in with a subscription"
829
- end
830
-
831
- def login_provider_choices(method)
832
- if method == :api_key
833
- ProviderCatalog.api_key_providers.map(&:name)
834
- else
835
- ["Anthropic Claude", "ChatGPT", "GitHub Copilot"]
836
- end
837
- end
838
-
839
- def selected_login_provider(selected)
840
- value = selected.to_s.downcase
841
- return "openai" if value == "openai" || value == "chatgpt"
842
- return "anthropic" if value == "anthropic" || value == "anthropic claude"
843
- return "copilot" if value == "github copilot"
844
-
845
- ProviderCatalog.find_by_name(selected)&.id
846
- end
847
-
848
- def model_overlay_available?
849
- @prompt.respond_to?(:select)
850
- end
851
-
852
- def settings_overlay_available?
853
- @prompt.respond_to?(:select)
854
- end
855
-
856
- def update_overlay_settings(values)
857
- settings = ConfigFiles.update_overlay_settings(values)
858
- @prompt.update_overlay_settings(settings) if @prompt.respond_to?(:update_overlay_settings)
859
- settings
860
- end
861
-
862
- def choose_overlay_setting(message, choices, values)
863
- choice = @prompt.select(message, choices, title: "Settings")
864
- return nil unless choice
865
-
866
- values.find { |value| choice.to_s.downcase.start_with?(value) }
867
- end
868
-
869
- def normalized_available_models(conversation = current_footer_conversation, models: nil)
870
- current_provider = conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
871
- current_model = conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : nil)
872
- current_reasoning = conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil)
873
- models ||= @client.respond_to?(:available_models) ? @client.available_models : []
874
- ModelInfo.normalize_available(
875
- models,
876
- current_provider: current_provider,
877
- current_model: current_model,
878
- current_reasoning_effort: current_reasoning
879
- )
880
- end
881
-
882
- def model_choices(models, conversation = current_footer_conversation)
883
- current_provider = conversation.provider || current_model_provider
884
- current_model = conversation.model || current_model_id
885
- choices = models.map do |model|
886
- label = "#{model[:provider]} #{model[:id]}"
887
- label += " (current)" if model[:current]
888
- label
889
- end
890
- choices.empty? ? ["#{current_provider} #{current_model} (current)"] : choices.uniq
891
- end
892
-
893
- def default_selectable_model?(model)
894
- return true if model[:current]
895
-
896
- capabilities = Array(model[:supportedParameters]).map { |value| value.to_s.downcase }
897
- return true if capabilities.empty?
898
-
899
- capabilities.any? do |capability|
900
- ["tools", "toolchoice", "functioncalling", "generatecontent", "streamgeneratecontent"].include?(capability.delete("_-"))
901
- end
902
- end
903
-
904
- def selected_model(selected, models)
905
- text = selected.to_s.sub(/ \(current\)\z/, "").strip
906
- known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
907
- return [known[:provider], known[:id]] if known
908
-
909
- provider, model = text.split(/\s+/, 2)
910
- known_provider = ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].include?(provider) || ProviderCatalog.find_by_name(provider)
911
- return [provider, model.strip] if known_provider && !model.to_s.strip.empty?
912
-
913
- [current_model_provider, text]
914
- end
915
-
916
- def persist_model_selection(provider, model, conversation)
917
- model = model.to_s.strip
918
- raise "Model must be a non-empty string" if model.empty?
919
-
920
- ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
921
- reload_client_config
922
- refresh_conversation_runtime(conversation)
923
- refresh_composer_status
924
- end
925
-
926
- REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
927
-
928
- def cycle_reasoning(conversation = current_footer_conversation, direction: :next, persist: :immediate)
929
- provider = conversation&.provider || current_model_provider
930
- model = conversation&.model || current_model_id
931
- choices = ModelInfo.reasoning_effort_choices(provider, model)
932
- return false if choices.empty?
933
-
934
- current = (pending_reasoning_effort(provider) || conversation&.reasoning_effort || current_reasoning_effort).to_s
935
- current_index = choices.index { |effort, _label| effort == current }
936
- current_index ||= direction == :previous ? 0 : -1
937
- offset = direction == :previous ? -1 : 1
938
- effort = choices[(current_index + offset) % choices.length].first
939
- persist == :debounced ? apply_reasoning_effort(effort, conversation, provider: provider) : set_reasoning_effort(effort, conversation, provider: provider)
940
- true
941
- rescue StandardError => e
942
- runtime_output("Reasoning error: #{e.message}")
943
- false
944
- end
945
-
946
- def set_reasoning_effort(effort, conversation = nil, provider: nil)
947
- @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = nil }
948
- persist_reasoning_config(effort, provider: provider)
949
- apply_reasoning_effort(effort, conversation, provider: provider, queue_config: false)
950
- end
951
-
952
- def apply_reasoning_effort(effort, conversation = nil, provider: nil, queue_config: true)
953
- queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
954
- if queue_config
955
- update_conversation_reasoning_effort(conversation, effort)
956
- refresh_composer_status
957
- else
958
- refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- refresh_composer_status
960
- end
961
- end
962
-
963
- def update_conversation_reasoning_effort(conversation, effort)
964
- return unless conversation&.respond_to?(:update_runtime_context!)
965
-
966
- conversation.update_runtime_context!(
967
- provider: conversation.provider || current_model_provider,
968
- model: conversation.model || current_model_id,
969
- reasoning_effort: effort,
970
- refresh: false
971
- )
972
- end
973
-
974
- def pending_reasoning_effort(provider)
975
- @pending_reasoning_config_mutex.synchronize do
976
- pending = @pending_reasoning_config
977
- return nil unless pending
978
- return nil unless pending[:provider].to_s.downcase == provider.to_s.downcase
979
-
980
- pending[:effort]
981
- end
982
- end
983
-
984
- def queue_reasoning_config(effort, provider: nil, conversation: nil)
985
- pending = {
986
- effort: effort,
987
- provider: provider || current_model_provider,
988
- conversation: conversation,
989
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + REASONING_CONFIG_DEBOUNCE_SECONDS
990
- }
991
- @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config = pending }
992
- schedule_reasoning_config_flush
993
- end
994
-
995
- def schedule_reasoning_config_flush
996
- return if @pending_reasoning_config_thread&.alive?
997
-
998
- @pending_reasoning_config_thread = Thread.new do
999
- loop do
1000
- sleep REASONING_CONFIG_DEBOUNCE_SECONDS
1001
- break if flush_pending_reasoning_config(force: false)
1002
- break unless @pending_reasoning_config_mutex.synchronize { @pending_reasoning_config }
1003
- end
1004
- rescue StandardError => e
1005
- runtime_output("Reasoning error: #{e.message}")
1006
- end
1007
- end
1008
-
1009
- def flush_pending_reasoning_config(force: true, conversation: nil)
1010
- pending = nil
1011
- @pending_reasoning_config_mutex.synchronize do
1012
- pending = @pending_reasoning_config
1013
- return false unless pending
1014
-
1015
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1016
- return false if !force && now < pending[:deadline].to_f
1017
-
1018
- @pending_reasoning_config = nil
1019
- end
1020
- persist_reasoning_config(pending[:effort], provider: pending[:provider])
1021
- conversation ||= pending[:conversation]
1022
- if conversation&.reasoning_effort.to_s == pending[:effort].to_s
1023
- refresh_conversation_runtime(conversation, reasoning_effort: pending[:effort])
1024
- conversation.persist_runtime_context! if conversation.respond_to?(:persist_runtime_context!)
1025
- end
1026
- true
1027
- end
1028
-
1029
- def persist_reasoning_config(effort, provider: nil)
1030
- ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(provider || current_model_provider) => effort)
1031
- reload_client_config
1032
- end
1033
-
1034
- def reasoning_choices(choices, conversation = current_footer_conversation)
1035
- current = (conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)).to_s
1036
- choices.map do |effort, label|
1037
- text = label.dup
1038
- text += " (current)" if current == effort
1039
- text
1040
- end
1041
- end
1042
-
1043
- def overlay_alignment_choices(settings)
1044
- ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
1045
- label = alignment.capitalize
1046
- label += " (current)" if settings["alignment"] == alignment
1047
- label
1048
- end
1049
- end
1050
-
1051
- def overlay_width_choices(settings)
1052
- ConfigFiles::OVERLAY_WIDTHS.map do |width|
1053
- label = width.capitalize
1054
- label += " (current)" if settings["width"] == width
1055
- label
1056
- end
1057
- end
1058
-
8
+ include Menus
9
+ include Model
1059
10
  end
1060
11
  end
1061
12
  end