kward 0.83.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -1,1061 +1,12 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|