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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- 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
|