kward 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,745 @@
1
+ # CLI settings menus behavior.
2
+ module Kward
3
+ class CLI
4
+ module Settings
5
+ module Menus
6
+ private
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
+ end
743
+ end
744
+ end
745
+ end