kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -0,0 +1,663 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Interactive settings menu actions mixed into the CLI frontend.
6
+ module Settings
7
+ private
8
+
9
+ def configure_settings(conversation = nil)
10
+ unless settings_overlay_available?
11
+ @prompt.say("\nSettings overlay is unavailable in this prompt.\n")
12
+ return
13
+ end
14
+
15
+ loop do
16
+ selected = @prompt.select("Settings category", settings_category_choices, title: "Settings")
17
+ category = selected_settings_category(selected)
18
+ break unless category
19
+
20
+ break if category == "done"
21
+
22
+ handle_settings_category(category, conversation)
23
+ end
24
+ rescue StandardError => e
25
+ @prompt.say("\nSettings error: #{e.message}\n")
26
+ end
27
+
28
+ # Returns the category labels shown by the interactive settings overlay.
29
+ def settings_category_choices
30
+ [
31
+ "Model & Reasoning",
32
+ "Accounts",
33
+ "Memory",
34
+ "Interface",
35
+ "Tools & Search",
36
+ "Context & Compaction",
37
+ "Personalization",
38
+ "Logging",
39
+ "Advanced",
40
+ "Done"
41
+ ]
42
+ end
43
+
44
+ # Maps a selected settings label back to the internal category key.
45
+ def selected_settings_category(selected)
46
+ text = selected.to_s.downcase
47
+ return nil if text.empty?
48
+ return "done" if text.start_with?("done")
49
+ return "model" if text.start_with?("model")
50
+ return "accounts" if text.start_with?("accounts")
51
+ return "memory" if text.start_with?("memory")
52
+ return "interface" if text.start_with?("interface")
53
+ return "tools" if text.start_with?("tools")
54
+ return "context" if text.start_with?("context")
55
+ return "personalization" if text.start_with?("personalization")
56
+ return "logging" if text.start_with?("logging")
57
+ return "advanced" if text.start_with?("advanced")
58
+
59
+ nil
60
+ end
61
+
62
+ def handle_settings_category(category, conversation)
63
+ case category
64
+ when "model"
65
+ configure_model_settings(conversation)
66
+ when "accounts"
67
+ configure_account_settings
68
+ when "memory"
69
+ configure_memory_settings(conversation)
70
+ when "interface"
71
+ configure_interface_settings
72
+ when "tools"
73
+ configure_tools_settings
74
+ when "context"
75
+ configure_context_settings
76
+ when "personalization"
77
+ configure_personalization_settings(conversation)
78
+ when "logging"
79
+ configure_logging_settings
80
+ when "advanced"
81
+ show_advanced_settings
82
+ end
83
+ end
84
+
85
+ def configure_model_settings(conversation)
86
+ selected = @prompt.select("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], title: "Settings")
87
+ case selected.to_s.downcase
88
+ when /\Aprovider/
89
+ configure_provider(conversation)
90
+ when /\Adefault model/
91
+ configure_model(conversation)
92
+ when /\Areasoning effort/
93
+ configure_reasoning(conversation)
94
+ end
95
+ end
96
+
97
+ def configure_provider(conversation)
98
+ selected = @prompt.select("Provider", provider_choices, title: "Settings")
99
+ provider = selected_provider(selected)
100
+ return unless provider
101
+
102
+ ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
103
+ reload_client_config
104
+ refresh_conversation_runtime(conversation)
105
+ @prompt.redraw if @prompt.respond_to?(:redraw)
106
+ end
107
+
108
+ def provider_choices
109
+ current = current_model_provider
110
+ ["Codex", "Anthropic", "OpenRouter", "Copilot"].map do |provider|
111
+ label = provider.dup
112
+ label += " (current)" if provider == current
113
+ label
114
+ end
115
+ end
116
+
117
+ def selected_provider(selected)
118
+ text = selected.to_s.downcase
119
+ return "Codex" if text.start_with?("codex")
120
+ return "Anthropic" if text.start_with?("anthropic") || text.start_with?("claude")
121
+ return "OpenRouter" if text.start_with?("openrouter")
122
+ return "Copilot" if text.start_with?("copilot")
123
+
124
+ nil
125
+ end
126
+
127
+ def configure_account_settings
128
+ selected = @prompt.select("Accounts", account_setting_choices, title: "Settings")
129
+ case selected.to_s.downcase
130
+ when /\Aopenai/
131
+ login(provider: "openai")
132
+ reload_client_config
133
+ when /\Aanthropic/, /\Aclaude/
134
+ login(provider: "anthropic")
135
+ reload_client_config
136
+ when /\Agithub/
137
+ login(provider: "github")
138
+ reload_client_config
139
+ when /\Aopenrouter/
140
+ login(provider: "openrouter")
141
+ reload_client_config
142
+ when /\Astatus/
143
+ print_auth_status
144
+ end
145
+ end
146
+
147
+ def account_setting_choices
148
+ config = safely_read_config.to_h
149
+ [
150
+ "OpenAI login (#{File.exist?(OpenAIOAuth.default_auth_path) ? "configured" : "not configured"})",
151
+ "Anthropic login (#{File.exist?(AnthropicOAuth.default_auth_path) ? "configured" : "not configured"})",
152
+ "GitHub login (#{File.exist?(GithubOAuth.default_auth_path) ? "configured" : "not configured"})",
153
+ "OpenRouter API key (#{openrouter_key_status(config)})",
154
+ "Status",
155
+ "Back"
156
+ ]
157
+ end
158
+
159
+ def openrouter_key_status(config)
160
+ return "configured via environment" unless ENV["OPENROUTER_API_KEY"].to_s.empty?
161
+
162
+ config["openrouter_api_key"].to_s.empty? ? "not configured" : "configured"
163
+ end
164
+
165
+ def configure_memory_settings(conversation)
166
+ selected = @prompt.select("Memory", memory_setting_choices, title: "Settings")
167
+ case selected.to_s.downcase
168
+ when /\Aenable memory/, /\Adisable memory/
169
+ set_memory_enabled(!memory_enabled?)
170
+ conversation&.refresh_system_message!
171
+ @prompt.say("\nMemory #{memory_enabled? ? "enabled" : "disabled"}.\n")
172
+ when /\Aenable auto-summary/, /\Adisable auto-summary/
173
+ set_memory_auto_summary_enabled(!memory_auto_summary_enabled?)
174
+ @prompt.say("\nMemory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.\n")
175
+ when /\Amanage/
176
+ @prompt.say("\nUse /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.\n")
177
+ end
178
+ end
179
+
180
+ def memory_setting_choices
181
+ [
182
+ "#{memory_enabled? ? "Disable" : "Enable"} memory (currently #{on_off(memory_enabled?)})",
183
+ "#{memory_auto_summary_enabled? ? "Disable" : "Enable"} auto-summary (currently #{on_off(memory_auto_summary_enabled?)})",
184
+ "Manage memories with /memory",
185
+ "Back"
186
+ ]
187
+ end
188
+
189
+ def memory_enabled?
190
+ memory = safely_read_config.to_h["memory"]
191
+ memory.is_a?(Hash) && memory["enabled"] == true
192
+ end
193
+
194
+ def memory_auto_summary_enabled?
195
+ memory = safely_read_config.to_h["memory"]
196
+ memory.is_a?(Hash) && memory["auto_summary"] == true
197
+ end
198
+
199
+ def set_memory_enabled(enabled)
200
+ update_nested_config("memory", "enabled" => enabled)
201
+ end
202
+
203
+ def set_memory_auto_summary_enabled(enabled)
204
+ update_nested_config("memory", "auto_summary" => enabled)
205
+ end
206
+
207
+ def configure_interface_settings
208
+ selected = @prompt.select("Interface", interface_setting_choices, title: "Settings")
209
+ case selected.to_s.downcase
210
+ when /\Aoverlay alignment/
211
+ settings = ConfigFiles.overlay_settings
212
+ alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
213
+ return unless alignment
214
+
215
+ @prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("alignment" => alignment))
216
+ when /\Aoverlay width/
217
+ settings = ConfigFiles.overlay_settings
218
+ width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
219
+ return unless width
220
+
221
+ @prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("width" => width))
222
+ when /\Ashow busy help/, /\Ahide busy help/
223
+ set_composer_busy_help(!composer_busy_help?)
224
+ @prompt.say("\nBusy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
225
+ when /\Ashow startup banner/, /\Ahide startup banner/
226
+ set_banner_enabled(!banner_enabled?)
227
+ @prompt.say("\nStartup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
228
+ when /\Aenable session auto-resume/, /\Adisable session auto-resume/
229
+ set_session_auto_resume_enabled(!session_auto_resume_enabled?)
230
+ @prompt.say("\nSession auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.\n")
231
+ end
232
+ end
233
+
234
+ def interface_setting_choices
235
+ settings = ConfigFiles.overlay_settings
236
+ [
237
+ "Overlay alignment (#{settings["alignment"]})",
238
+ "Overlay width (#{settings["width"]})",
239
+ "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
240
+ "#{banner_enabled? ? "Hide" : "Show"} startup banner (currently #{on_off(banner_enabled?)})",
241
+ "#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
242
+ "Back"
243
+ ]
244
+ end
245
+
246
+ def composer_busy_help?
247
+ ConfigFiles.composer_busy_help?(safely_read_config.to_h)
248
+ end
249
+
250
+ def banner_enabled?
251
+ ConfigFiles.banner_enabled?(safely_read_config.to_h)
252
+ end
253
+
254
+ def session_auto_resume_enabled?
255
+ ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
256
+ end
257
+
258
+ def set_composer_busy_help(enabled)
259
+ update_nested_config("composer", "busy_help" => enabled)
260
+ end
261
+
262
+ def set_banner_enabled(enabled)
263
+ update_nested_config("banner", "enabled" => enabled)
264
+ end
265
+
266
+ def set_session_auto_resume_enabled(enabled)
267
+ update_nested_config("sessions", "auto_resume" => enabled)
268
+ end
269
+
270
+ def configure_tools_settings
271
+ selected = @prompt.select("Tools & Search", tools_setting_choices, title: "Settings")
272
+ case selected.to_s.downcase
273
+ when /\Aenable web search/, /\Adisable web search/
274
+ set_web_search_enabled(!web_search_enabled?)
275
+ @prompt.say("\nWeb search #{web_search_enabled? ? "enabled" : "disabled"}.\n")
276
+ when /\Aweb search provider/
277
+ configure_web_search_provider
278
+ when /\Aallow model-provider/, /\Adisallow model-provider/
279
+ set_web_search_allow_model_providers(!web_search_allow_model_providers?)
280
+ @prompt.say("\nModel-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.\n")
281
+ when /\Aenable workspace guardrails/, /\Adisable workspace guardrails/
282
+ set_workspace_guardrails_enabled(!workspace_guardrails_enabled?)
283
+ @prompt.say("\nWorkspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.\n")
284
+ end
285
+ end
286
+
287
+ def tools_setting_choices
288
+ [
289
+ "#{web_search_enabled? ? "Disable" : "Enable"} web search (currently #{on_off(web_search_enabled?)})",
290
+ "Web search provider (#{web_search_provider})",
291
+ "#{web_search_allow_model_providers? ? "Disallow" : "Allow"} model-provider web search (currently #{on_off(web_search_allow_model_providers?)})",
292
+ "#{workspace_guardrails_enabled? ? "Disable" : "Enable"} workspace guardrails (currently #{on_off(workspace_guardrails_enabled?)})",
293
+ "Back"
294
+ ]
295
+ end
296
+
297
+ def configure_web_search_provider
298
+ providers = WebSearch::PROVIDERS
299
+ selected = @prompt.select("Web search provider", providers.map { |provider| provider == web_search_provider ? "#{provider} (current)" : provider }, title: "Settings")
300
+ provider = providers.find { |value| selected.to_s.downcase.start_with?(value) }
301
+ return unless provider
302
+
303
+ update_nested_config("web_search", "provider" => provider)
304
+ end
305
+
306
+ def web_search_config
307
+ ConfigFiles.web_search_config(safely_read_config.to_h)
308
+ end
309
+
310
+ def web_search_enabled?
311
+ web_search_config["enabled"] != false
312
+ end
313
+
314
+ def web_search_provider
315
+ web_search_config["provider"].to_s.empty? ? "auto" : web_search_config["provider"].to_s
316
+ end
317
+
318
+ def web_search_allow_model_providers?
319
+ web_search_config["allow_model_providers"] == true
320
+ end
321
+
322
+ def set_web_search_enabled(enabled)
323
+ update_nested_config("web_search", "enabled" => enabled)
324
+ end
325
+
326
+ def set_web_search_allow_model_providers(enabled)
327
+ update_nested_config("web_search", "allow_model_providers" => enabled)
328
+ end
329
+
330
+ def set_workspace_guardrails_enabled(enabled)
331
+ update_nested_config("tools", "workspace_guardrails" => enabled)
332
+ end
333
+
334
+ def configure_context_settings
335
+ selected = @prompt.select("Context & Compaction", context_setting_choices, title: "Settings")
336
+ case selected.to_s.downcase
337
+ when /\Aenable auto-compaction/, /\Adisable auto-compaction/
338
+ set_compaction_enabled(!compaction_enabled?)
339
+ @prompt.say("\nAuto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.\n")
340
+ else
341
+ @prompt.say("\n#{auto_compaction_status_line}\n") if selected.to_s.downcase.start_with?("status")
342
+ end
343
+ end
344
+
345
+ def context_setting_choices
346
+ [
347
+ "#{compaction_enabled? ? "Disable" : "Enable"} auto-compaction (currently #{on_off(compaction_enabled?)})",
348
+ "Status",
349
+ "Back"
350
+ ]
351
+ end
352
+
353
+ def compaction_enabled?
354
+ Kward::Compaction::Settings.from_config(safely_read_config.to_h).enabled
355
+ end
356
+
357
+ def set_compaction_enabled(enabled)
358
+ update_nested_config("compaction", "enabled" => enabled)
359
+ end
360
+
361
+ def configure_personalization_settings(conversation)
362
+ selected = @prompt.select("Personalization", personalization_setting_choices(conversation), title: "Settings")
363
+ case selected.to_s.downcase
364
+ when /\Adefault persona/
365
+ configure_default_persona(conversation)
366
+ when /\Aactive instructions/
367
+ show_active_instructions_summary(conversation)
368
+ end
369
+ end
370
+
371
+ def personalization_setting_choices(conversation)
372
+ [
373
+ "Default persona (#{default_persona_label})",
374
+ "Active instructions summary",
375
+ "Back"
376
+ ]
377
+ end
378
+
379
+ def default_persona_label
380
+ personas = safely_read_config.to_h["personas"]
381
+ value = personas.is_a?(Hash) ? personas["default"] : nil
382
+ value.to_s.empty? ? "none" : value.to_s
383
+ end
384
+
385
+ def configure_default_persona(conversation)
386
+ config = safely_read_config.to_h
387
+ personas = config["personas"].is_a?(Hash) ? config["personas"] : {}
388
+ entries = ConfigFiles.crew_character_labels(personas)
389
+ choices = entries.map { |key, label| key == personas["default"] ? "#{label} (#{key}, current)" : "#{label} (#{key})" }
390
+ if choices.empty?
391
+ @prompt.say("\nNo configured personas found. Edit #{ConfigFiles.config_path} to add personas.\n")
392
+ return
393
+ end
394
+
395
+ selected = @prompt.select("Default persona", choices, title: "Settings")
396
+ key = entries.keys.find { |candidate| selected.to_s.include?("(#{candidate}") }
397
+ return unless key
398
+
399
+ personas = personas.dup
400
+ personas["default"] = key
401
+ ConfigFiles.update_config("personas" => personas)
402
+ conversation&.refresh_system_message!
403
+ @prompt.redraw if @prompt.respond_to?(:redraw)
404
+ end
405
+
406
+ def show_active_instructions_summary(conversation)
407
+ label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
408
+ lines = ["Active persona: #{label || "none"}"]
409
+ lines << "Global AGENTS.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
410
+ lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
411
+ lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
412
+ @prompt.say("\n#{lines.join("\n")}\n")
413
+ end
414
+
415
+ def configure_logging_settings
416
+ selected = @prompt.select("Logging", logging_setting_choices, title: "Settings")
417
+ key = logging_key_for_choice(selected)
418
+ return unless key
419
+
420
+ set_logging_value(key, !logging_enabled?(key))
421
+ @prompt.say("\nLogging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.\n")
422
+ end
423
+
424
+ def logging_setting_choices
425
+ [
426
+ "#{logging_enabled?("enabled") ? "Disable" : "Enable"} local logging (currently #{on_off(logging_enabled?("enabled"))})",
427
+ "#{logging_enabled?("tokens") ? "Disable" : "Enable"} token logs (currently #{on_off(logging_enabled?("tokens"))})",
428
+ "#{logging_enabled?("performance") ? "Disable" : "Enable"} performance logs (currently #{on_off(logging_enabled?("performance"))})",
429
+ "#{logging_enabled?("tools") ? "Disable" : "Enable"} tool logs (currently #{on_off(logging_enabled?("tools"))})",
430
+ "#{logging_enabled?("errors") ? "Disable" : "Enable"} error logs (currently #{on_off(logging_enabled?("errors"))})",
431
+ "Back"
432
+ ]
433
+ end
434
+
435
+ def logging_key_for_choice(selected)
436
+ text = selected.to_s.downcase
437
+ return "enabled" if text.include?("local logging")
438
+ return "tokens" if text.include?("token logs")
439
+ return "performance" if text.include?("performance logs")
440
+ return "tools" if text.include?("tool logs")
441
+ return "errors" if text.include?("error logs")
442
+
443
+ nil
444
+ end
445
+
446
+ def logging_enabled?(key)
447
+ logging = safely_read_config.to_h["logging"]
448
+ logging.is_a?(Hash) && logging[key] == true
449
+ end
450
+
451
+ def set_logging_value(key, value)
452
+ update_nested_config("logging", key => value)
453
+ end
454
+
455
+ def show_advanced_settings
456
+ lines = [
457
+ "Config path: #{ConfigFiles.config_path}",
458
+ "Config directory: #{ConfigFiles.config_dir}",
459
+ "Cache directory: #{ConfigFiles.cache_dir}",
460
+ "Memory directory: #{ConfigFiles.memory_dir}",
461
+ "Plugin directory: #{ConfigFiles.plugin_dir}",
462
+ "Plugins: #{ConfigFiles.plugin_paths.length}",
463
+ "Skills: #{ConfigFiles.skills.length}",
464
+ "Prompt templates: #{ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).length}"
465
+ ]
466
+ @prompt.say("\n#{lines.join("\n")}\n")
467
+ end
468
+
469
+ def update_nested_config(section, values)
470
+ config = ConfigFiles.read_config
471
+ current = config[section].is_a?(Hash) ? config[section].dup : {}
472
+ config[section] = current.merge(values)
473
+ ConfigFiles.write_config(config)
474
+ config
475
+ end
476
+
477
+ def on_off(value)
478
+ value ? "on" : "off"
479
+ end
480
+
481
+ def login_interactively
482
+ unless login_picker_available?
483
+ @prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
484
+ return
485
+ end
486
+
487
+ selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
488
+ provider = selected_login_provider(selected)
489
+ return unless provider
490
+
491
+ login(provider: provider)
492
+ reload_client_config
493
+ rescue StandardError => e
494
+ @prompt.say("\nLogin error: #{e.message}\n")
495
+ end
496
+
497
+ def configure_model(conversation = nil, models: nil)
498
+ unless model_overlay_available?
499
+ @prompt.say("\nModel overlay is unavailable in this prompt.\n")
500
+ return
501
+ end
502
+
503
+ models ||= normalized_available_models
504
+ choices = model_choices(models)
505
+ selected = @prompt.select("Default model", choices, title: "Models", custom: true)
506
+ return unless selected
507
+
508
+ provider, model = selected_model(selected, models)
509
+ raise "Model must be a non-empty string" if model.to_s.strip.empty?
510
+
511
+ ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
512
+ reload_client_config
513
+ refresh_conversation_runtime(conversation)
514
+ @prompt.redraw if @prompt.respond_to?(:redraw)
515
+ rescue StandardError => e
516
+ @prompt.say("\nModel error: #{e.message}\n")
517
+ end
518
+
519
+ # Writes the openrouter catalog output for the terminal CLI flow.
520
+ def print_openrouter_catalog
521
+ unless @client.respond_to?(:openrouter_catalog)
522
+ @prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
523
+ return
524
+ end
525
+
526
+ models = Array(@client.openrouter_catalog)
527
+ if models.empty?
528
+ @prompt.say("\nNo OpenRouter catalog models available.\n")
529
+ else
530
+ ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
531
+ @prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
532
+ end
533
+ rescue StandardError => e
534
+ @prompt.say("\nOpenRouter catalog error: #{e.message}\n")
535
+ end
536
+
537
+ def configure_reasoning(conversation = nil)
538
+ unless model_overlay_available?
539
+ @prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
540
+ return
541
+ end
542
+
543
+ choices = ModelInfo.reasoning_effort_choices(current_model_provider, current_model_id)
544
+ if choices.empty?
545
+ @prompt.say("\nReasoning effort is unavailable for #{current_model_provider} #{current_model_id}.\n")
546
+ return
547
+ end
548
+
549
+ selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
550
+ return unless selected
551
+
552
+ effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
553
+ raise "Reasoning effort must be one of: #{choices.map(&:first).join(", ")}" unless effort
554
+
555
+ ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
556
+ reload_client_config
557
+ refresh_conversation_runtime(conversation)
558
+ @prompt.redraw if @prompt.respond_to?(:redraw)
559
+ rescue StandardError => e
560
+ @prompt.say("\nReasoning error: #{e.message}\n")
561
+ end
562
+
563
+ def login_picker_available?
564
+ @prompt.respond_to?(:select)
565
+ end
566
+
567
+ def login_provider_choices
568
+ ["OpenAI", "Anthropic", "OpenRouter", "GitHub"]
569
+ end
570
+
571
+ def selected_login_provider(selected)
572
+ case selected.to_s.downcase
573
+ when /\Aopenai\b/
574
+ "openai"
575
+ when /\Aanthropic\b/, /\Aclaude\b/
576
+ "anthropic"
577
+ when /\Aopenrouter\b/
578
+ "openrouter"
579
+ when /\Agithub\b/
580
+ "github"
581
+ end
582
+ end
583
+
584
+ def model_overlay_available?
585
+ @prompt.respond_to?(:select)
586
+ end
587
+
588
+ def settings_overlay_available?
589
+ @prompt.respond_to?(:select) && @prompt.respond_to?(:update_overlay_settings)
590
+ end
591
+
592
+ def choose_overlay_setting(message, choices, values)
593
+ choice = @prompt.select(message, choices, title: "Settings")
594
+ return nil unless choice
595
+
596
+ values.find { |value| choice.to_s.downcase.start_with?(value) }
597
+ end
598
+
599
+ def normalized_available_models
600
+ current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
601
+ current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
602
+ current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
603
+ models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
604
+ models.map do |model|
605
+ ModelInfo.normalize(
606
+ model,
607
+ current_provider: current_provider,
608
+ current_model: current_model,
609
+ current_reasoning_effort: current_reasoning
610
+ )
611
+ end
612
+ end
613
+
614
+ def model_choices(models)
615
+ choices = models.map do |model|
616
+ label = "#{model[:provider]} #{model[:id]}"
617
+ label += " (current)" if model[:current]
618
+ label
619
+ end
620
+ choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
621
+ end
622
+
623
+ def selected_model(selected, models)
624
+ text = selected.to_s.sub(/ \(current\)\z/, "").strip
625
+ known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
626
+ return [known[:provider], known[:id]] if known
627
+
628
+ provider, model = text.split(/\s+/, 2)
629
+ if ["Codex", "Anthropic", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
630
+ [provider, model.strip]
631
+ else
632
+ [current_model_provider, text]
633
+ end
634
+ end
635
+
636
+ def reasoning_choices(choices)
637
+ current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
638
+ choices.map do |effort, label|
639
+ text = label.dup
640
+ text += " (current)" if current == effort
641
+ text
642
+ end
643
+ end
644
+
645
+ def overlay_alignment_choices(settings)
646
+ ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
647
+ label = alignment.capitalize
648
+ label += " (current)" if settings["alignment"] == alignment
649
+ label
650
+ end
651
+ end
652
+
653
+ def overlay_width_choices(settings)
654
+ ConfigFiles::OVERLAY_WIDTHS.map do |width|
655
+ label = width.capitalize
656
+ label += " (current)" if settings["width"] == width
657
+ label
658
+ end
659
+ end
660
+
661
+ end
662
+ end
663
+ end