返回 CodeWhale
config.rs
根目录 / crates / tui / src / commands / groups / config / config.rs
1 //! Config commands: config, settings, mode switches, trust, logout
2
3 use super::CommandResult;
4 use crate::config::{
5 Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
6 DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, DEFAULT_XIAOMI_MIMO_BASE_URL,
7 MAX_STREAM_CHUNK_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS,
8 MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENTS, MIN_STREAM_CHUNK_TIMEOUT_SECS,
9 MIN_SUBAGENT_API_TIMEOUT_SECS, MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, NotificationConfigUpdate,
10 NotificationSetting, NotificationsConfig, ProviderKind, SearchProvider, SearchProviderSource,
11 SubagentsConfig, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key,
12 normalize_custom_model_id, normalize_model_name_for_provider, validate_route,
13 };
14 use crate::config_persistence::{
15 persist_root_bool_key, persist_root_string_key, persist_subagents_bool_key,
16 persist_subagents_integer_key, persist_table_string_key, persist_table_value_key,
17 persist_unset_root_key,
18 };
19 use crate::reasoning_preference::ReasoningEffort;
20 use crate::settings::Settings;
21 use crate::tui::app::{App, AppAction, OnboardingState, ScreenMode, SettingSelection, VimMode};
22 use anyhow::Result;
23 use codewhale_config::AppMode;
24 use codewhale_execpolicy::ApprovalMode;
25 use codewhale_localization::{MessageId, resolve_locale, tr};
26 use std::path::{Path, PathBuf};
27
28 /// Open the interactive config editor.
29 ///
30 /// One settings surface: bare `/config` and `/config tui|web|native` all
31 /// open the canonical ConfigView (the `OpenConfigView` action). The legacy
32 /// schemaui editors are gone; the mode words remain accepted so muscle
33 /// memory lands in the right place instead of an error.
34 pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
35 match arg.unwrap_or("").trim().to_ascii_lowercase().as_str() {
36 "" | "native" | "tui" | "web" => CommandResult::action(AppAction::OpenConfigView),
37 other => CommandResult::error(format!(
38 "Usage: /config [native|tui|web] — unknown editor `{other}`"
39 )),
40 }
41 }
42
43 /// Dispatch `/config` with optional args.
44 ///
45 /// - `/config` (no args) — opens the canonical ConfigView.
46 /// - `/config tui` / `/config web` / `/config native` — the same ConfigView
47 /// (the words are accepted for muscle memory, not separate editors).
48 /// - `/config ask-rules` — compatibility entry for `/permissions`.
49 /// - `/config <key>` — shows the current value of a setting.
50 /// - `/config <key> <value>` — sets a runtime value (session only, add --save to persist).
51 pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
52 let raw = arg.map(str::trim).unwrap_or("");
53 if raw.is_empty() {
54 return show_config(app, None);
55 }
56 if matches!(
57 raw.to_ascii_lowercase().as_str(),
58 "audit" | "editability" | "editable" | "status"
59 ) {
60 return config_editability_audit(app);
61 }
62 let mut raw_words = raw.splitn(2, char::is_whitespace);
63 let first_word = raw_words.next();
64 if first_word.is_some_and(is_ask_rules_config_token) {
65 let rest = raw_words.next().unwrap_or("").trim();
66 return super::permissions::permissions_command(app, Some(rest));
67 }
68 if first_word.is_some_and(|token| {
69 token.eq_ignore_ascii_case("workflow") || token.eq_ignore_ascii_case("goal")
70 }) && raw_words
71 .clone()
72 .next()
73 .is_none_or(|rest| rest.trim().is_empty())
74 {
75 return super::workflow_settings(app);
76 }
77 if first_word.is_some_and(|token| token.eq_ignore_ascii_case("subagents")) {
78 let rest = raw_words.next().unwrap_or("").trim();
79 return subagents_config_command(app, rest);
80 }
81 if first_word.is_some_and(|token| token.eq_ignore_ascii_case("search")) {
82 let rest = raw_words.next().unwrap_or("").trim();
83 return search_config_command(app, rest);
84 }
85 if first_word.is_some_and(|token| {
86 token.eq_ignore_ascii_case("notifications") || token.eq_ignore_ascii_case("notification")
87 }) {
88 let rest = raw_words.next().unwrap_or("").trim();
89 return notifications_config_command(app, rest);
90 }
91 // `/config preset <name> [--save|-s]` — apply a bundled settings preset (#3478).
92 if first_word.is_some_and(|token| token.eq_ignore_ascii_case("preset")) {
93 let rest = raw_words.next().unwrap_or("").trim();
94 return config_preset_command(app, rest);
95 }
96 let parts: Vec<&str> = raw.splitn(2, ' ').collect();
97 if parts.len() == 1 {
98 // Single arg: editor-mode shortcut OR show-value request.
99 let token = parts[0];
100 if matches!(
101 token.to_ascii_lowercase().as_str(),
102 "tui" | "web" | "native"
103 ) {
104 return show_config(app, Some(token));
105 }
106 // `/config <key>` — show current value
107 show_single_setting(app, token)
108 } else {
109 // `/config <key> <value> [--save|-s]` — set value, optionally persist
110 let raw_value = parts[1];
111 let persist = raw_value.ends_with(" --save") || raw_value.ends_with(" -s");
112 let value = if persist {
113 raw_value
114 .strip_suffix(" --save")
115 .or_else(|| raw_value.strip_suffix(" -s"))
116 .unwrap_or(raw_value)
117 } else {
118 raw_value
119 };
120 set_config_value(app, parts[0], value, persist)
121 }
122 }
123
124 /// Reject a preset bundle *before* anything is written, returning the message
125 /// to show, or `None` when every field can be applied.
126 ///
127 /// The bundle is persisted in one transaction and then mirrored field by field
128 /// into the live session. A per-field refusal during that mirror pass therefore
129 /// arrives *after* the file has already been rewritten — the user gets an error
130 /// and a saved file, which is the partial apply this preflight exists to make
131 /// impossible. Both refusals a field can raise are knowable up front:
132 ///
133 /// 1. A live-route key while a turn is running (#2982).
134 /// 2. A value the setter would reject, checked against a throwaway `Settings`
135 /// so the real file is never touched by the check.
136 fn preset_preflight(app: &App, fields: &[(&str, &str)]) -> Option<String> {
137 for (key, value) in fields {
138 if app.is_loading
139 && let Some(subject) = live_route_setting_subject(&key.to_lowercase())
140 {
141 return Some(app.setting_locked_message(subject));
142 }
143 if let Err(e) = Settings::default().set(key, value) {
144 return Some(format!("Failed to apply preset field {key}={value}: {e}"));
145 }
146 }
147 None
148 }
149
150 /// Apply a bundled settings preset, e.g. `/config preset calm [--save]` (#3478).
151 ///
152 /// The preset is applied to the live session through the same per-key setter a
153 /// single `/config <key> <value>` uses, so app state mirroring and (with
154 /// `--save`) persistence stay consistent. The preset name is validated before
155 /// any field is touched.
156 fn config_preset_command(app: &mut App, rest: &str) -> CommandResult {
157 let tokens: Vec<&str> = rest.split_whitespace().collect();
158 let persist = matches!(tokens.last(), Some(&"--save") | Some(&"-s"));
159 let name = tokens.first().copied().unwrap_or("");
160 if name.is_empty() || name.starts_with('-') {
161 return CommandResult::message(
162 "Usage: /config preset <name> [--save]. Available presets: calm.",
163 );
164 }
165
166 let Some(fields) = crate::settings::preset_fields(name) else {
167 return CommandResult::error(format!("Unknown preset '{name}'. Available presets: calm."));
168 };
169
170 if let Some(refusal) = preset_preflight(app, fields) {
171 return CommandResult::error(refusal);
172 }
173
174 // Persist the whole bundle atomically when requested (one load/apply/save),
175 // now that every field is known to be applicable.
176 if persist {
177 // `Settings::transact` is what makes "one load/apply/save" true against
178 // the *other* writers in this process, not just against a second preset
179 // apply: an unsynchronized load/save pair here would write back a
180 // pre-image that reverts a concurrent mode/thinking/posture write.
181 if let Err(e) = Settings::transact(|settings| settings.apply_preset(name)) {
182 return CommandResult::error(format!("Failed to save settings: {e}"));
183 }
184 }
185
186 // Mirror the bundle into the live session via the per-key setter (the
187 // persisted write, if any, already happened atomically above, so this pass
188 // is session-only).
189 let mut applied = Vec::with_capacity(fields.len());
190 for (key, value) in fields {
191 let result = set_config_value(app, key, value, false);
192 if result.is_error {
193 let message = result
194 .message
195 .unwrap_or_else(|| "unknown apply error".to_string());
196 return CommandResult::error(format!(
197 "Failed to apply preset field {key}={value}: {message}"
198 ));
199 }
200 applied.push(format!("{key}={value}"));
201 }
202
203 let suffix = if persist {
204 " (saved)"
205 } else {
206 " (session only — add --save to persist)"
207 };
208 CommandResult::message(format!(
209 "Applied '{name}' transcript preset{suffix}: {}. Thinking stays visible and tool runs stay expandable.",
210 applied.join(", ")
211 ))
212 }
213
214 /// Show the current value of a single setting.
215 fn config_context_window_override(app: &App) -> Option<u32> {
216 let mut config = Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?;
217 let identity = app.admitted_provider_identity().ok()?;
218 config.scope_to_provider_identity(identity).ok()?;
219 config.context_window_for_provider_config(identity)
220 }
221
222 fn show_single_setting(app: &App, key: &str) -> CommandResult {
223 let key = key.to_lowercase();
224 if let Some(subagent_key) = key.strip_prefix("subagents.") {
225 return show_subagents_setting(app, subagent_key);
226 }
227 if let Some(notifications_key) = key.strip_prefix("notifications.") {
228 return show_notifications_setting(app, notifications_key);
229 }
230 fn locale_display(l: codewhale_localization::Locale) -> &'static str {
231 match l {
232 codewhale_localization::Locale::En => "en",
233 codewhale_localization::Locale::ZhHans => "zh-Hans",
234 codewhale_localization::Locale::ZhHant => "zh-Hant",
235 codewhale_localization::Locale::Ja => "ja",
236 codewhale_localization::Locale::PtBr => "pt-BR",
237 codewhale_localization::Locale::Es419 => "es-419",
238 codewhale_localization::Locale::Vi => "vi",
239 codewhale_localization::Locale::Ko => "ko",
240 codewhale_localization::Locale::Ca => "ca",
241 codewhale_localization::Locale::De => "de",
242 codewhale_localization::Locale::Fr => "fr",
243 codewhale_localization::Locale::Id => "id",
244 codewhale_localization::Locale::Hi => "hi",
245 codewhale_localization::Locale::Ru => "ru",
246 codewhale_localization::Locale::Uk => "uk",
247 }
248 }
249 fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str {
250 match d {
251 crate::tui::app::ComposerDensity::Compact => "compact",
252 crate::tui::app::ComposerDensity::Comfortable => "comfortable",
253 crate::tui::app::ComposerDensity::Spacious => "spacious",
254 }
255 }
256 fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str {
257 match s {
258 crate::tui::app::TranscriptSpacing::Compact => "compact",
259 crate::tui::app::TranscriptSpacing::Comfortable => "comfortable",
260 crate::tui::app::TranscriptSpacing::Spacious => "spacious",
261 }
262 }
263 let value = match key.as_str() {
264 "model" => {
265 if app.auto_model {
266 let mut label = "auto (auto-select model per turn)".to_string();
267 if let Some(effective) = app.last_effective_model.as_deref()
268 && effective != "auto"
269 {
270 label.push_str(&format!("; last: {effective}"));
271 }
272 Some(label)
273 } else {
274 Some(app.model.clone())
275 }
276 }
277 "provider" => Some(app.provider_identity_for_persistence().to_string()),
278 "approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()),
279 "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()),
280 "base_url" => {
281 let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
282 {
283 Ok(config) => config,
284 Err(err) => {
285 return CommandResult::error(format!("Failed to load config: {err}"));
286 }
287 };
288 Some(config.active_route_base_url())
289 }
290 // `/config title` reports the config-level default, not a session's
291 // `/title` override. The latter is intentionally a separate setting
292 // and is reported by bare `/title`.
293 "title" | "window_title" | "tab_title" => Some(
294 app.title_default
295 .clone()
296 .unwrap_or_else(|| "(unset)".to_string()),
297 ),
298 "provider_url" | "provider_base_url" | "endpoint" => {
299 let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
300 {
301 Ok(mut config) => {
302 config.provider = Some(app.provider_identity_for_persistence().to_string());
303 config
304 }
305 Err(err) => {
306 return CommandResult::error(format!("Failed to load config: {err}"));
307 }
308 };
309 Some(config.active_route_base_url())
310 }
311 "context_window" | "context_window_tokens" => Some(format!(
312 "{} (effective {} from {})",
313 config_context_window_override(app)
314 .map_or_else(|| "not set".to_string(), |tokens| tokens.to_string()),
315 crate::route_budget::route_context_window_tokens(
316 app.api_provider,
317 app.effective_model_for_budget(),
318 app.active_route_limits,
319 ),
320 app.active_context_window_source.display_label(),
321 )),
322 "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()),
323 "posture_bar" => Some(app.posture_bar.as_setting().to_string()),
324 "metrics_line" => Some(app.metrics_line.as_setting().to_string()),
325 "locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
326 "theme" | "ui_theme" => Some(
327 if app
328 .theme_name
329 .starts_with(codewhale_palette::USER_THEME_PREFIX)
330 {
331 app.theme_name.clone()
332 } else {
333 codewhale_palette::theme_label_for_mode(app.ui_theme.mode).to_string()
334 },
335 ),
336 "background_color" | "background" | "bg" => {
337 codewhale_palette::hex_rgb_string(app.ui_theme.surface_bg)
338 .or_else(|| Some("(default)".to_string()))
339 }
340 "auto_compact" | "compact" => {
341 Some(if app.auto_compact { "true" } else { "false" }.to_string())
342 }
343 "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
344 "low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()),
345 "fancy_animations" | "fancy" | "animations" => Some(
346 if app.fancy_animations {
347 "true"
348 } else {
349 "false"
350 }
351 .to_string(),
352 ),
353 "bracketed_paste" | "paste" => Some(
354 if app.use_bracketed_paste {
355 "true"
356 } else {
357 "false"
358 }
359 .to_string(),
360 ),
361 "paste_burst_detection" | "paste_burst" => Some(
362 if app.use_paste_burst_detection {
363 "true"
364 } else {
365 "false"
366 }
367 .to_string(),
368 ),
369 "show_thinking" | "thinking" => {
370 Some(if app.show_thinking { "true" } else { "false" }.to_string())
371 }
372 "thinking_default_expanded" | "thinking_expanded" => Some(
373 if app.thinking_default_expanded {
374 "true"
375 } else {
376 "false"
377 }
378 .to_string(),
379 ),
380 "thinking_preview_lines" | "thinking_preview" => {
381 Some(app.thinking_preview_lines.to_string())
382 }
383 "help_expand_groups" | "help_expanded" => Some(
384 if app.help_expand_groups {
385 "true"
386 } else {
387 "false"
388 }
389 .to_string(),
390 ),
391 "contextual_tips" => Some(app.behavioral_tips.enabled().to_string()),
392 "pin_last_prompt" | "pin_prompt" => {
393 Some(if app.pin_last_prompt { "true" } else { "false" }.to_string())
394 }
395 "thinking_highlight" | "reasoning_highlight" => Some(
396 if app.thinking_highlight {
397 "true"
398 } else {
399 "false"
400 }
401 .to_string(),
402 ),
403 "show_tool_details" | "tool_details" => Some(
404 if app.show_tool_details {
405 "true"
406 } else {
407 "false"
408 }
409 .to_string(),
410 ),
411 "inline_diffs" | "inline_diff" | "diffs" => {
412 Some(app.inline_diff_mode.as_setting().to_string())
413 }
414 "mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
415 "max_history" | "history" => Some(app.max_input_history.to_string()),
416 "work_surface_placement" | "work_surface" | "work_rail" => {
417 Some(app.work_surface.placement.as_setting().to_string())
418 }
419 "rail_panel" | "rail" => Some(app.work_surface.panel.as_setting().to_string()),
420 "work_surface_top_height" | "work_top_height" => {
421 Some(app.work_surface.top_height.to_string())
422 }
423 "work_surface_side_width" | "work_side_width" => {
424 Some(app.work_surface.side_width.to_string())
425 }
426 "tool_collapse" | "tool_collapse_mode" | "collapse" => {
427 Some(app.tool_collapse_mode.as_setting().to_string())
428 }
429 "context_panel" | "context" | "session_panel" => {
430 Some(if app.context_panel { "true" } else { "false" }.to_string())
431 }
432 "sessions_rail" | "sessions_panel" | "session_rail" => {
433 Some(if app.sessions_rail { "true" } else { "false" }.to_string())
434 }
435 // Read the persisted value rather than reporting a hard-coded default:
436 // this setting is consumed at startup by `main`, so `App` has no live
437 // copy, and printing "false" unconditionally would misreport a user who
438 // has it on.
439 "session_auto_resume" | "auto_resume" => Some(
440 if crate::settings::Settings::load_persisted()
441 .map(|settings| settings.session_auto_resume)
442 .unwrap_or(false)
443 {
444 "true"
445 } else {
446 "false"
447 }
448 .to_string(),
449 ),
450 "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()),
451 "composer_border" | "border" => {
452 Some(if app.composer_border { "true" } else { "false" }.to_string())
453 }
454 "composer_multiline_mode" | "multiline_mode" | "multiline" => Some(
455 if app.composer_multiline_mode {
456 "true"
457 } else {
458 "false"
459 }
460 .to_string(),
461 ),
462 "composer_vim_mode" | "vim_mode" | "vim" => Some(
463 if app.composer.vim_enabled {
464 "vim"
465 } else {
466 "normal"
467 }
468 .to_string(),
469 ),
470 "transcript_spacing" | "spacing" => {
471 Some(spacing_display(app.transcript_spacing).to_string())
472 }
473 "status_indicator" | "indicator" => Some(app.status_indicator.clone()),
474 "synchronized_output" | "sync_output" | "sync" => Some(
475 if app.synchronized_output_enabled {
476 "on"
477 } else {
478 "off"
479 }
480 .to_string(),
481 ),
482 "cost_currency" | "currency" => Some(
483 match app.cost_currency {
484 crate::pricing::CostCurrency::Usd => "usd",
485 crate::pricing::CostCurrency::Cny => "cny",
486 }
487 .to_string(),
488 ),
489 "default_model" => match saved_deepseek_default_model(app) {
490 Ok(model) => Some(model),
491 Err(error) => return CommandResult::error(error),
492 },
493 "reasoning_effort" | "effort" => Some(
494 app.reasoning_effort
495 .as_setting_for_provider(app.api_provider)
496 .to_string(),
497 ),
498 "workspace_follow_symlinks" | "follow_symlinks" => Settings::load().ok().map(|settings| {
499 format!(
500 "{} (restart required for engine tools)",
501 settings.workspace_follow_symlinks
502 )
503 }),
504 "search" | "search.provider" | "search_provider" => load_command_config(app)
505 .ok()
506 .map(|config| search_provider_display(&config, app.ui_locale)),
507 "telemetry" => load_command_config(app)
508 .ok()
509 .map(|config| crate::telemetry_notice::saved_preference_enabled(&config).to_string()),
510 "prompt_suggestion" => load_command_config(app)
511 .ok()
512 .map(|config| prompt_suggestion_display(&config)),
513 "notifications" => Some(notifications_summary_value(&app.notification_settings)),
514 _ => {
515 // Any spelling `/set` accepts; internal flags, actions and
516 // receipts in the schema are not settings a user can look up.
517 let known = Settings::canonical_key(&key).is_some();
518 if known {
519 Some("(see /settings for current value)".to_string())
520 } else {
521 None
522 }
523 }
524 };
525 match value {
526 Some(v) => CommandResult::message(format!("{key} = {v}")),
527 None => CommandResult::error(unknown_setting_message(&key)),
528 }
529 }
530
531 /// Error for `/config <key>` when `key` is not a known setting: name the
532 /// closest real key when there is one, and point at the full list.
533 fn unknown_setting_message(key: &str) -> String {
534 // Suggest only keys `/set` accepts, the same set `known` checks above
535 // (#6563).
536 let nearest = crate::config_keys::nearest_key(key, crate::config_keys::settings_toml_keys());
537 match nearest {
538 Some(candidate) => format!(
539 "Unknown setting '{key}'. Did you mean `/config {candidate}`? Run `/settings text` to list every setting."
540 ),
541 None => format!("Unknown setting '{key}'. Run `/settings text` to list every setting."),
542 }
543 }
544
545 /// Open the typed settings editor. `text` preserves the legacy diagnostic
546 /// output for scripts and terminals that cannot render the modal.
547 pub fn settings_command(app: &mut App, arg: Option<&str>) -> CommandResult {
548 match arg.map(str::trim).filter(|value| !value.is_empty()) {
549 None => CommandResult::action(AppAction::OpenConfigView),
550 Some("text" | "show" | "diagnostic" | "diagnostics") => show_settings(app),
551 Some(_) => CommandResult::error("Usage: /settings [text]"),
552 }
553 }
554
555 /// Show persistent settings as plain text (legacy compatibility path).
556 pub fn show_settings(app: &mut App) -> CommandResult {
557 match Settings::load() {
558 Ok(settings) => CommandResult::message(settings.display(app.ui_locale)),
559 Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
560 }
561 }
562
563 /// Open the `/statusline` multi-select picker for configuring footer items.
564 pub fn status_line(_app: &mut App) -> CommandResult {
565 CommandResult::action(AppAction::OpenStatusPicker)
566 }
567
568 /// Toggle whether the live transcript renders full thinking detail.
569 pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult {
570 let next = match arg.map(str::trim).filter(|s| !s.is_empty()) {
571 None => !app.verbose_transcript,
572 Some(raw) => match raw.to_ascii_lowercase().as_str() {
573 "on" | "true" | "1" | "yes" => true,
574 "off" | "false" | "0" | "no" => false,
575 "toggle" => !app.verbose_transcript,
576 _ => {
577 return CommandResult::error(
578 "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.",
579 );
580 }
581 },
582 };
583
584 app.verbose_transcript = next;
585 app.mark_history_updated();
586 CommandResult::message(if next {
587 "Verbose transcript on: live thinking renders in full."
588 } else {
589 "Verbose transcript off: live thinking stays compact."
590 })
591 }
592
593 /// `/fullscreen` and `/inline`: move the TUI between the alternate screen and
594 /// a full-height inline viewport.
595 ///
596 /// The terminal transition happens where the ratatui terminal lives — this
597 /// only emits the action, so a switch the terminal refuses can roll back and
598 /// explain itself there.
599 pub fn screen(app: &mut App, target: ScreenMode, arg: Option<&str>) -> CommandResult {
600 if let Some(extra) = arg.map(str::trim).filter(|value| !value.is_empty()) {
601 return CommandResult::error(format!(
602 "/{} takes no argument (got {extra:?}). Use /fullscreen or /inline.",
603 target.as_str()
604 ));
605 }
606 if target == app.screen_mode {
607 return CommandResult::message(match target {
608 ScreenMode::Fullscreen => {
609 "Already on the fullscreen screen (alternate screen). /inline keeps the terminal's own scrollback instead."
610 }
611 ScreenMode::Inline => {
612 "Already inline: a full-height viewport with no alternate screen, so this terminal's scrollback survives the session. /fullscreen returns to the alternate screen."
613 }
614 });
615 }
616 CommandResult::action(AppAction::SetScreenMode(target))
617 }
618
619 /// Place the workbar or pick its panel.
620 ///
621 /// `/workbar bottom|top|left|right|off` sets placement; `/workbar
622 /// tasks|agents|context|pinned` picks the panel. The two are orthogonal:
623 /// where the workbar sits and what it shows. `/rail` and `/sidebar` remain
624 /// registered as the aliases users know.
625 /// Bare `/workbar` reports the workbar's *actual* rendered state — never a
626 /// claim about a surface that cannot render.
627 pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
628 const USAGE: &str =
629 "Usage: /workbar [bottom|top|left|right|off|tasks|agents|context|pinned] [--save]";
630 let raw = arg.map(str::trim).unwrap_or("");
631 let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
632 let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
633 if persist {
634 tokens.pop();
635 }
636
637 match tokens.as_slice() {
638 [] => return CommandResult::message(rail_status_message(app)),
639 [value] => {
640 let value = value.to_ascii_lowercase();
641 // Legacy focus words map onto the closest workbar concept so muscle
642 // memory keeps working: "on" restores the default bottom workbar,
643 // "off" hides it, panel names select panels.
644 let placement = match value.as_str() {
645 "top" => Some(crate::tui::work_surface::WorkSurfacePlacement::Top),
646 "bottom" | "on" | "show" | "visible" => {
647 Some(crate::tui::work_surface::WorkSurfacePlacement::Bottom)
648 }
649 "left" => Some(crate::tui::work_surface::WorkSurfacePlacement::Left),
650 "right" => Some(crate::tui::work_surface::WorkSurfacePlacement::Right),
651 "off" | "hide" | "hidden" | "closed" | "none" => {
652 Some(crate::tui::work_surface::WorkSurfacePlacement::Off)
653 }
654 _ => None,
655 };
656 let panel = match value.as_str() {
657 "tasks" | "activity" | "live" | "running" | "pinned" | "work" | "plan"
658 | "todos" => Some(crate::tui::work_surface::RailPanel::Tasks),
659 "agents" | "subagents" | "sub-agents" => {
660 Some(crate::tui::work_surface::RailPanel::Agents)
661 }
662 "background" | "shells" | "jobs" => {
663 Some(crate::tui::work_surface::RailPanel::Background)
664 }
665 "files" | "changes" => Some(crate::tui::work_surface::RailPanel::Files),
666 "notepad" | "notes" => Some(crate::tui::work_surface::RailPanel::Notepad),
667 "context" | "session" => Some(crate::tui::work_surface::RailPanel::Context),
668 "git" | "branch" => Some(crate::tui::work_surface::RailPanel::Git),
669 "price" | "cost" => Some(crate::tui::work_surface::RailPanel::Price),
670 _ => None,
671 };
672 match (placement, panel) {
673 (Some(placement), None) => {
674 app.work_surface.placement = placement;
675 app.work_surface.focused = false;
676 if persist {
677 let result = set_config_value(
678 app,
679 "work_surface_placement",
680 placement.as_setting(),
681 true,
682 );
683 if result.is_error {
684 return result;
685 }
686 }
687 }
688 (None, Some(panel)) => {
689 crate::tui::work_surface::select_dock_panel(app, panel);
690 if persist {
691 let result = set_config_value(app, "rail_panel", panel.as_setting(), true);
692 if result.is_error {
693 return result;
694 }
695 }
696 }
697 _ => return CommandResult::error(USAGE),
698 }
699 }
700 _ => return CommandResult::error(USAGE),
701 }
702
703 app.needs_redraw = true;
704 CommandResult::message(rail_status_message(app))
705 }
706
707 /// `/pet`: turn the terminal over to the Codewhale pet.
708 ///
709 /// Bare `/pet` toggles. `on` enters the full habitat now and lets every
710 /// accepted turn re-enter it until `off`. The habitat is a modal over the
711 /// existing shell: composer draft, transcript, selection and the active
712 /// Engine turn stay underneath, and Escape returns without cancelling
713 /// anything. The remaining verbs address the shared companion: the browser
714 /// appearance studio, the native window, source selection, replay export and
715 /// the single audio lease. The pet has no workbar panel.
716 pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult {
717 const USAGE: &str = "Usage: /pet [on|off|status|appearance|window|source|export|sound on|off]";
718 use crate::tui::pet_watch::{self, Control};
719 let words = arg
720 .map(str::trim)
721 .unwrap_or("")
722 .split_whitespace()
723 .map(str::to_ascii_lowercase)
724 .collect::<Vec<_>>();
725 let words = words.iter().map(String::as_str).collect::<Vec<_>>();
726 let mode = |app: &mut App, enabled: bool| {
727 pet_watch::set_enabled(app, enabled);
728 CommandResult::message(tr(
729 app.ui_locale,
730 if enabled {
731 MessageId::PetModeOn
732 } else {
733 MessageId::PetModeOff
734 },
735 ))
736 };
737 let queued = |app: &mut App, control: Control| {
738 pet_watch::command(app, control);
739 CommandResult::message(tr(app.ui_locale, MessageId::PetHabitatQueued))
740 };
741 match words.as_slice() {
742 [] => {
743 let enabled = !app.pet_watch.enabled;
744 mode(app, enabled)
745 }
746 ["on"] => mode(app, true),
747 ["off"] => mode(app, false),
748 ["status"] => CommandResult::message(format!(
749 "{} · {} · {}",
750 tr(
751 app.ui_locale,
752 if app.pet_watch.enabled {
753 MessageId::PetModeOnLabel
754 } else {
755 MessageId::PetModeOffLabel
756 }
757 ),
758 tr(
759 app.ui_locale,
760 if pet_watch::is_open(app) {
761 MessageId::PetViewOpen
762 } else {
763 MessageId::PetViewClosed
764 }
765 ),
766 app.pet_watch.status()
767 )),
768 ["appearance"] => queued(app, Control::Browser),
769 ["window"] => queued(app, Control::Window),
770 ["source"] => queued(app, Control::Select),
771 ["export"] => {
772 if app.pet_watch.export() {
773 CommandResult::message(tr(app.ui_locale, MessageId::PetWatchExportQueued))
774 } else {
775 CommandResult::error(tr(app.ui_locale, MessageId::PetWatchExportUnavailable))
776 }
777 }
778 ["sound", rest @ ..] => {
779 let enabled = match rest {
780 [] => None,
781 ["on"] => Some(true),
782 ["off"] => Some(false),
783 _ => return CommandResult::error(USAGE),
784 };
785 if let Some(enabled) = enabled {
786 app.pet_watch.set_sound(enabled);
787 app.needs_redraw = true;
788 }
789 let label = if enabled == Some(true) {
790 MessageId::PetWatchSoundOn
791 } else {
792 app.pet_watch.sound_label()
793 };
794 CommandResult::message(format!("{} · /pet sound on|off", tr(app.ui_locale, label)))
795 }
796 _ => CommandResult::error(USAGE),
797 }
798 }
799
800 /// Truthful workbar readout: the placement and panel that actually render,
801 /// with the narrow-terminal fallback and an empty-Tasks collapse spelled out.
802 /// Never claims a panel is visible when no workbar area was produced.
803 fn rail_status_message(app: &App) -> String {
804 use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement};
805
806 let placement = app.work_surface.placement;
807 if placement == WorkSurfacePlacement::Off {
808 return "Workbar is off — no panel renders (/workbar bottom|top|left|right to show it)"
809 .to_string();
810 }
811 let panel = app.work_surface.panel;
812 let mut message = format!(
813 "Workbar: {} placement, {} panel",
814 placement.as_setting(),
815 panel.title()
816 );
817 let effective = app.work_surface.effective_placement();
818 if effective != placement && effective == WorkSurfacePlacement::Top {
819 message.push_str(" — side placements need a wider terminal, showing top for now");
820 }
821 if app.work_surface.last_area.is_none() {
822 if panel == RailPanel::Tasks {
823 message.push_str(" (currently hidden — no work to show)");
824 } else {
825 message.push_str(" (renders next frame)");
826 }
827 }
828 message
829 }
830
831 fn resolve_provider_url_value(provider: ProviderKind, value: &str) -> Result<String, String> {
832 let trimmed = value.trim();
833 if trimmed.is_empty() {
834 return Err("provider_url cannot be empty".to_string());
835 }
836
837 if provider == ProviderKind::XiaomiMimo {
838 match trimmed.to_ascii_lowercase().as_str() {
839 "token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => {
840 return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string());
841 }
842 "payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => {
843 return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string());
844 }
845 _ => {}
846 }
847 }
848
849 if trimmed.contains("://") {
850 Ok(trimmed.to_string())
851 } else if provider == ProviderKind::XiaomiMimo {
852 Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string())
853 } else {
854 Err("provider_url must be a URL".to_string())
855 }
856 }
857
858 fn parse_config_bool(value: &str) -> Result<bool, String> {
859 match value.trim().to_ascii_lowercase().as_str() {
860 "on" | "true" | "yes" | "1" | "enabled" => Ok(true),
861 "off" | "false" | "no" | "0" | "disabled" => Ok(false),
862 _ => Err(format!(
863 "Failed to parse boolean '{value}': expected on/off, true/false, yes/no."
864 )),
865 }
866 }
867
868 fn approval_mode_config_value(mode: ApprovalMode) -> &'static str {
869 match mode {
870 ApprovalMode::Auto => "auto",
871 ApprovalMode::Bypass => "bypass",
872 ApprovalMode::Suggest => "on-request",
873 ApprovalMode::Never => "never",
874 }
875 }
876
877 fn is_ask_rules_config_token(token: &str) -> bool {
878 matches!(
879 token.to_ascii_lowercase().as_str(),
880 "ask-rules"
881 | "ask_rules"
882 | "askrules"
883 | "rules"
884 | "permission-rules"
885 | "permission_rules"
886 | "permissions"
887 )
888 }
889
890 fn config_editability_audit(app: &App) -> CommandResult {
891 let config = match load_command_config(app) {
892 Ok(config) => config,
893 Err(err) => return CommandResult::error(err),
894 };
895 let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
896 .map(|path| path.display().to_string())
897 .unwrap_or_else(|_| "(unresolved)".to_string());
898
899 let mut provider_config = config.clone();
900 provider_config.provider = Some(app.provider_identity_for_persistence().to_string());
901 let model = if app.auto_model {
902 "auto".to_string()
903 } else {
904 app.model.clone()
905 };
906 let saved_permission_posture = Settings::load()
907 .ok()
908 .and_then(|settings| settings.permission_posture)
909 .unwrap_or_else(|| "(unset)".to_string());
910 let configured_approval_policy = config
911 .approval_policy
912 .clone()
913 .unwrap_or_else(|| "(unset)".to_string());
914 let effective_permissions = if app.mode == AppMode::Plan {
915 "Read Only"
916 } else {
917 app.approval_mode.permission_chip_label()
918 };
919 let search_audit_note = tr(app.ui_locale, MessageId::ConfigAuditSearchProvider);
920 let prompt_audit_note = tr(app.ui_locale, MessageId::ConfigAuditPromptSuggestion);
921 let notifications_audit_note = tr(app.ui_locale, MessageId::ConfigAuditNotifications);
922
923 let rows = [
924 (
925 "provider",
926 app.provider_identity_for_persistence().to_string(),
927 "session",
928 "/config provider <name>",
929 "Switches the active provider now; edit provider in config.toml for startup default.",
930 ),
931 (
932 "model",
933 model,
934 "session",
935 "/config model <id|auto>",
936 "Switches the active model now; use default_text_model in config.toml for startup default.",
937 ),
938 (
939 "effective_permissions",
940 effective_permissions.to_string(),
941 "runtime",
942 "Shift+Tab",
943 "Shows the effective Act permission posture; Plan remains Read Only.",
944 ),
945 (
946 "permission_posture",
947 saved_permission_posture,
948 "TUI settings",
949 "Shift+Tab",
950 "Saved in settings.toml and ignored when config/requirements manage approval policy.",
951 ),
952 (
953 "approval_policy",
954 configured_approval_policy,
955 "persisted config",
956 "/config approval_mode <auto|on-request|never> --save",
957 "Top-level managed policy; Full Access is not a valid value here.",
958 ),
959 (
960 "allow_shell",
961 app.allow_shell.to_string(),
962 "runtime+persisted",
963 "/config allow_shell <true|false> --save",
964 "Writes top-level allow_shell and applies to subsequent turns.",
965 ),
966 (
967 "stream_chunk_timeout_secs",
968 app.stream_chunk_timeout_secs.to_string(),
969 "runtime+persisted",
970 "/config stream_chunk_timeout_secs <0|1..3600> --save",
971 "Writes [stream].chunk_timeout_secs and updates the running stream timeout.",
972 ),
973 (
974 "posture_bar",
975 app.posture_bar.as_setting().to_string(),
976 "runtime+persisted",
977 "/config posture_bar <full|compact|hidden> --save",
978 "Writes [tui].posture_bar; hidden gives the row to the transcript, compact keeps the posture chips only.",
979 ),
980 (
981 "metrics_line",
982 app.metrics_line.as_setting().to_string(),
983 "runtime+persisted",
984 "/config metrics_line <full|compact|hidden> --save",
985 "Writes [tui].metrics_line; hidden gives the row to the transcript, compact drops secondary counts and help while keeping selected TTFT/rate readings when they fit. Choose readings with /statusline.",
986 ),
987 (
988 "subagents.enabled",
989 subagents_config_display_value(&config, "enabled"),
990 "runtime+persisted",
991 "/config subagents on|off --save",
992 "Writes [subagents].enabled and updates subsequent sub-agent launches.",
993 ),
994 (
995 "subagents.max_concurrent",
996 subagents_config_display_value(&config, "max_concurrent"),
997 "runtime+persisted",
998 "/config subagents max_concurrent <n> --save",
999 "Clamped with Config::max_subagents and written to [subagents].max_concurrent.",
1000 ),
1001 (
1002 "subagents.max_depth",
1003 subagents_config_display_value(&config, "max_depth"),
1004 "runtime+persisted",
1005 "/config subagents max_depth <n> --save",
1006 "Clamped to the configured spawn-depth ceiling.",
1007 ),
1008 (
1009 "subagents.launch_concurrency",
1010 subagents_config_display_value(&config, "launch_concurrency"),
1011 "runtime+persisted",
1012 "/config subagents launch_concurrency <n> --save",
1013 "Clamped to the resolved sub-agent concurrency cap.",
1014 ),
1015 (
1016 "subagents.api_timeout_secs",
1017 subagents_config_display_value(&config, "api_timeout_secs"),
1018 "runtime+persisted",
1019 "/config subagents api_timeout_secs <seconds> --save",
1020 "0 means the compiled default; non-zero values are clamped to the documented range.",
1021 ),
1022 (
1023 "subagents.heartbeat_timeout_secs",
1024 subagents_config_display_value(&config, "heartbeat_timeout_secs"),
1025 "runtime+persisted",
1026 "/config subagents heartbeat_timeout_secs <seconds> --save",
1027 "0 means the compiled default; non-zero values are clamped to the documented range.",
1028 ),
1029 (
1030 "base_url",
1031 config.active_route_base_url(),
1032 "persisted restart",
1033 "/config base_url <url> --save",
1034 "Writes top-level base_url; model clients read it on startup.",
1035 ),
1036 (
1037 "providers.<active>.base_url",
1038 provider_config.active_route_base_url(),
1039 "persisted restart",
1040 "/config provider_url <url> --save",
1041 "Writes the active provider table; model clients read it on startup.",
1042 ),
1043 (
1044 "providers.<active>.context_window",
1045 config_context_window_override(app)
1046 .map_or_else(|| "(unset)".to_string(), |tokens| tokens.to_string()),
1047 "persisted restart",
1048 "edit [providers.<active>] context_window = <tokens>",
1049 "Overrides compaction, context-pressure, header, and preflight input budgets; use 262144 to cap a 1M route to 256K.",
1050 ),
1051 (
1052 "effective_context_window",
1053 format!(
1054 "{} ({})",
1055 crate::route_budget::route_context_window_tokens(
1056 app.api_provider,
1057 app.effective_model_for_budget(),
1058 app.active_route_limits,
1059 ),
1060 app.active_context_window_source.display_label(),
1061 ),
1062 "runtime",
1063 "/config context_window",
1064 "The shared resolved window used by every active-route budget surface.",
1065 ),
1066 (
1067 "mcp_config_path",
1068 app.mcp_config_path.display().to_string(),
1069 "persisted live reload",
1070 "/config mcp_config_path <path> --save",
1071 "Run /mcp reload to rebuild the live model-visible tool pool.",
1072 ),
1073 (
1074 "workspace_follow_symlinks",
1075 app.workspace_follow_symlinks.to_string(),
1076 "partial restart",
1077 "/config workspace_follow_symlinks <true|false> --save",
1078 "Updates TUI file completion now; engine tools require restart.",
1079 ),
1080 (
1081 "search.provider",
1082 search_provider_display(&config, app.ui_locale),
1083 "runtime+persisted",
1084 "/config search.provider <name> --save",
1085 search_audit_note.as_ref(),
1086 ),
1087 (
1088 "prompt_suggestion",
1089 prompt_suggestion_display(&config),
1090 "runtime+persisted",
1091 "/config prompt_suggestion <true|false> --save",
1092 prompt_audit_note.as_ref(),
1093 ),
1094 (
1095 "notifications",
1096 notifications_summary(&config),
1097 "runtime+persisted",
1098 "/config notifications <method|threshold_secs|quiet|completion_sound> <value> --save",
1099 notifications_audit_note.as_ref(),
1100 ),
1101 (
1102 "instructions",
1103 file_only_status(config.instructions.as_ref().map(|v| !v.is_empty())),
1104 "file-only restart",
1105 "edit config.toml",
1106 "Prompt layers are loaded before the first turn.",
1107 ),
1108 (
1109 "hooks",
1110 file_only_status(config.hooks.as_ref().map(|_| true)),
1111 "file-only",
1112 "edit config.toml",
1113 "Hook definitions are structured TOML, not a scalar runtime setting.",
1114 ),
1115 (
1116 "network",
1117 file_only_status(config.network.as_ref().map(|_| true)),
1118 "file-only",
1119 "edit config.toml",
1120 "Network policy is evaluated by tool dispatch and should be reviewed as TOML.",
1121 ),
1122 (
1123 "tools",
1124 file_only_status(config.tools.as_ref().map(|_| true)),
1125 "file-only restart",
1126 "edit config.toml",
1127 "Tool catalog policy is built before model/tool negotiation.",
1128 ),
1129 (
1130 "memory",
1131 file_only_status(config.memory.as_ref().map(|_| true)),
1132 "file-only restart",
1133 "edit config.toml",
1134 "Memory loading changes prompt context and is resolved at startup.",
1135 ),
1136 (
1137 "runtime_api",
1138 file_only_status(config.runtime_api.as_ref().map(|_| true)),
1139 "file-only restart",
1140 "edit config.toml",
1141 "Serve/API tuning belongs to the runtime server startup path.",
1142 ),
1143 (
1144 "vision_model",
1145 file_only_status(config.vision_model.as_ref().map(|_| true)),
1146 "file-only restart",
1147 "edit config.toml",
1148 "Image-analysis provider clients are configured outside the scalar /config editor.",
1149 ),
1150 ];
1151
1152 let mut lines = Vec::new();
1153 lines.push("Config editability audit".to_string());
1154 lines.push(format!("Config path: {config_path}"));
1155 lines.push("Key | Current | Editability | Command / reason".to_string());
1156 for (key, current, editability, command, note) in rows {
1157 lines.push(format!("{key} | {current} | {editability} | {command}"));
1158 lines.push(format!(" {note}"));
1159 }
1160 CommandResult::message(lines.join("\n"))
1161 }
1162
1163 fn file_only_status(configured: Option<bool>) -> String {
1164 match configured {
1165 Some(true) => "configured".to_string(),
1166 Some(false) => "empty".to_string(),
1167 None => "unset".to_string(),
1168 }
1169 }
1170
1171 fn search_provider_display(config: &Config, locale: codewhale_localization::Locale) -> String {
1172 let resolved = config.search_provider_resolution();
1173 let source = match resolved.source {
1174 SearchProviderSource::Default => tr(locale, MessageId::ConfigDefaultValue)
1175 .trim_matches(&['(', ')'][..])
1176 .to_string(),
1177 SearchProviderSource::Config => "config.toml".to_string(),
1178 SearchProviderSource::EnvOverride => "CODEWHALE_SEARCH_PROVIDER".to_string(),
1179 // Same token doctor prints: the signal is a Tavily key, not a disk
1180 // pin, so never name `TAVILY_API_KEY` (the winner may have been a
1181 // generic `tvly-` `[search] api_key`).
1182 SearchProviderSource::TavilyKey => "tavily key".to_string(),
1183 };
1184 tr(locale, MessageId::ConfigCommandSource)
1185 .replace("{value}", resolved.provider.as_str())
1186 .replace("{source}", &source)
1187 }
1188
1189 fn prompt_suggestion_display(config: &Config) -> String {
1190 config.prompt_suggestion_enabled().to_string()
1191 }
1192
1193 fn notifications_summary(config: &Config) -> String {
1194 notifications_summary_value(&config.notifications_config())
1195 }
1196
1197 fn notifications_summary_value(notifications: &NotificationsConfig) -> String {
1198 format!(
1199 "method={} threshold={}s sound={} quiet={}",
1200 notifications.method.as_str(),
1201 notifications.threshold_secs,
1202 notifications.display(NotificationSetting::Sound),
1203 notifications.quiet
1204 )
1205 }
1206
1207 fn search_config_command(app: &mut App, raw: &str) -> CommandResult {
1208 let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
1209 let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
1210 if persist {
1211 tokens.pop();
1212 }
1213
1214 match tokens.as_slice() {
1215 [] | ["status"] | ["provider"] => show_single_setting(app, "search.provider"),
1216 ["provider", value] | [value] => set_search_provider(app, value, persist),
1217 _ => CommandResult::error(format!(
1218 "{} /config search.provider <{}> [--save]",
1219 tr(app.ui_locale, MessageId::HelpUsageLabel),
1220 SearchProvider::names_hint()
1221 )),
1222 }
1223 }
1224
1225 fn set_search_provider(app: &mut App, value: &str, persist: bool) -> CommandResult {
1226 let Some(provider) = SearchProvider::parse(value) else {
1227 return CommandResult::error(
1228 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
1229 .replace("{key}", "search.provider")
1230 .replace("{value}", value)
1231 .replace("{choices}", SearchProvider::names_hint()),
1232 );
1233 };
1234
1235 let scope = if persist {
1236 match persist_table_string_key(
1237 app.config_path.as_deref(),
1238 "search",
1239 "provider",
1240 provider.as_str(),
1241 ) {
1242 Ok(path) => format!(
1243 "{} {}",
1244 tr(app.ui_locale, MessageId::ConfigScopeSaved),
1245 path.display()
1246 ),
1247 Err(err) => {
1248 return CommandResult::error(
1249 tr(app.ui_locale, MessageId::StartupDefaultNotSaved)
1250 .replace("{setting}", "search.provider")
1251 .replace("{error}", &err.to_string()),
1252 );
1253 }
1254 }
1255 } else {
1256 tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned()
1257 };
1258
1259 CommandResult::with_message_and_action(
1260 tr(app.ui_locale, MessageId::ConfigSearchUpdated)
1261 .replace("{value}", provider.as_str())
1262 .replace("{scope}", &scope),
1263 AppAction::UpdateSearchProvider { provider },
1264 )
1265 }
1266
1267 fn set_prompt_suggestion(app: &mut App, value: &str, persist: bool) -> CommandResult {
1268 let enabled = match parse_config_bool(value) {
1269 Ok(enabled) => enabled,
1270 Err(_) => {
1271 return CommandResult::error(
1272 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
1273 .replace("{key}", "prompt_suggestion")
1274 .replace("{value}", value)
1275 .replace("{choices}", "on, off, true, false, yes, no"),
1276 );
1277 }
1278 };
1279 let scope = if persist {
1280 match persist_root_bool_key(app.config_path.as_deref(), "prompt_suggestion", enabled) {
1281 Ok(path) => format!(
1282 "{} {}",
1283 tr(app.ui_locale, MessageId::ConfigScopeSaved),
1284 path.display()
1285 ),
1286 Err(err) => {
1287 return CommandResult::error(
1288 tr(app.ui_locale, MessageId::StartupDefaultNotSaved)
1289 .replace("{setting}", "prompt_suggestion")
1290 .replace("{error}", &err.to_string()),
1291 );
1292 }
1293 }
1294 } else {
1295 tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned()
1296 };
1297 CommandResult::with_message_and_action(
1298 tr(app.ui_locale, MessageId::ConfigPromptSuggestionUpdated)
1299 .replace("{value}", &enabled.to_string())
1300 .replace("{scope}", &scope),
1301 AppAction::UpdatePromptSuggestion { enabled },
1302 )
1303 }
1304
1305 fn notifications_config_command(app: &mut App, raw: &str) -> CommandResult {
1306 let raw = raw.trim();
1307 let (raw, persist) = raw
1308 .strip_suffix(" --save")
1309 .or_else(|| raw.strip_suffix(" -s"))
1310 .map_or((raw, false), |value| (value.trim_end(), true));
1311 if raw.is_empty() || raw == "status" {
1312 return show_notifications_status(app);
1313 }
1314 match raw.split_once(char::is_whitespace) {
1315 Some((key, value)) => set_notifications_value(app, key, value.trim(), persist),
1316 None => show_notifications_setting(app, raw),
1317 }
1318 }
1319
1320 fn show_notifications_status(app: &App) -> CommandResult {
1321 let mut lines = vec!["[notifications]".to_string()];
1322 lines.extend(NotificationSetting::ALL.into_iter().map(|setting| {
1323 format!(
1324 "{} = {}",
1325 setting.key(),
1326 app.notification_settings.display(setting)
1327 )
1328 }));
1329 lines.push(tr(app.ui_locale, MessageId::ConfigNotificationsSetHint).into_owned());
1330 CommandResult::message(lines.join("\n"))
1331 }
1332
1333 fn show_notifications_setting(app: &App, key: &str) -> CommandResult {
1334 let Some(setting) = NotificationSetting::parse(key) else {
1335 return invalid_notification_value(app, key, key, "/config notifications status");
1336 };
1337 CommandResult::message(format!(
1338 "notifications.{} = {}",
1339 setting.key(),
1340 app.notification_settings.display(setting)
1341 ))
1342 }
1343
1344 fn invalid_notification_value(app: &App, key: &str, value: &str, choices: &str) -> CommandResult {
1345 CommandResult::error(
1346 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
1347 .replace("{key}", &format!("notifications.{key}"))
1348 .replace("{value}", value)
1349 .replace("{choices}", choices),
1350 )
1351 }
1352
1353 fn set_notifications_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
1354 let Some(setting) = NotificationSetting::parse(key) else {
1355 return invalid_notification_value(app, key, value, "/config notifications status");
1356 };
1357 let update = match NotificationConfigUpdate::parse(setting, value) {
1358 Ok(update) => update,
1359 Err(_) => return invalid_notification_value(app, setting.key(), value, setting.choices()),
1360 };
1361 let scope = if persist {
1362 let result = crate::config_persistence::config_toml_path(app.config_path.as_deref())
1363 .and_then(|path| {
1364 update.persist_for_profile(&path, app.config_profile.as_deref())?;
1365 Ok(path)
1366 });
1367 match result {
1368 Ok(path) => format!(
1369 "{} {}",
1370 tr(app.ui_locale, MessageId::ConfigScopeSaved),
1371 path.display()
1372 ),
1373 Err(error) => {
1374 return CommandResult::error(
1375 tr(app.ui_locale, MessageId::StartupDefaultNotSaved)
1376 .replace("{setting}", &format!("notifications.{}", setting.key()))
1377 .replace("{error}", &error.to_string()),
1378 );
1379 }
1380 }
1381 } else {
1382 tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned()
1383 };
1384 CommandResult::with_message_and_action(
1385 tr(app.ui_locale, MessageId::ConfigNotificationUpdated)
1386 .replace("{key}", setting.key())
1387 .replace("{value}", &update.display())
1388 .replace("{scope}", &scope),
1389 AppAction::UpdateNotification { update },
1390 )
1391 }
1392
1393 fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
1394 if raw == 0 {
1395 format!("0 (default {resolved})")
1396 } else {
1397 resolved.to_string()
1398 }
1399 }
1400
1401 fn subagents_config_command(app: &mut App, raw: &str) -> CommandResult {
1402 let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
1403 let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
1404 if persist {
1405 tokens.pop();
1406 }
1407
1408 match tokens.as_slice() {
1409 [] | ["status"] => subagents_status(app),
1410 ["on"] | ["enable"] | ["enabled"] => {
1411 set_subagents_config_value(app, "enabled", "true", persist)
1412 }
1413 ["off"] | ["disable"] | ["disabled"] => {
1414 set_subagents_config_value(app, "enabled", "false", persist)
1415 }
1416 [key] => show_subagents_setting(app, key),
1417 [key, value] => set_subagents_config_value(app, key, value, persist),
1418 _ => CommandResult::error(
1419 "Usage: /config subagents [status|on|off|enabled|max_concurrent|max_depth|launch_concurrency|api_timeout_secs|heartbeat_timeout_secs <value>] [--save]",
1420 ),
1421 }
1422 }
1423
1424 fn load_command_config(app: &App) -> Result<Config, String> {
1425 Config::load(app.config_path.clone(), app.config_profile.as_deref())
1426 .map_err(|err| format!("Failed to load config: {err}"))
1427 }
1428
1429 /// The compatibility default_model command describes saved DeepSeek config,
1430 /// independent of the active provider and one-launch environment overrides.
1431 fn saved_deepseek_default_model(app: &App) -> Result<String, String> {
1432 let path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
1433 .map_err(|error| format!("Failed to resolve config: {error}"))?;
1434 let read = || -> anyhow::Result<String> {
1435 let store = codewhale_config::ConfigStore::load(Some(path.clone()))?;
1436 let mut config = Config::from_saved_document(
1437 store.original_body().unwrap_or(""),
1438 app.config_profile.as_deref(),
1439 )?;
1440 if app.config_profile.is_none() && crate::config::is_home_config_path(&path) {
1441 config.apply_saved_selection(&Settings::load_legacy_route_preferences_read_only()?);
1442 }
1443 let key = if app.admitted_provider_identity().is_ok_and(|identity| {
1444 identity.key.as_str() == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id
1445 }) {
1446 codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id
1447 } else {
1448 ProviderKind::Deepseek.as_str()
1449 };
1450 let identity = config
1451 .resolve_provider_pin_identity(key)
1452 .map_err(anyhow::Error::msg)?;
1453 anyhow::ensure!(
1454 identity.provider == ProviderKind::Deepseek,
1455 "The saved provider identity is shadowed by another route"
1456 );
1457 config
1458 .scope_to_provider_identity(&identity)
1459 .map_err(anyhow::Error::msg)?;
1460 Ok(config.default_model())
1461 };
1462 read().map_err(|error| format!("Failed to read saved model config: {error}"))
1463 }
1464
1465 fn subagents_status(app: &App) -> CommandResult {
1466 let config = match load_command_config(app) {
1467 Ok(config) => config,
1468 Err(err) => return CommandResult::error(err),
1469 };
1470 let path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
1471 .map(|path| path.display().to_string())
1472 .unwrap_or_else(|_| "(unresolved)".to_string());
1473 let disabled_reason = config.subagents_disabled_reason();
1474 let identity = match app
1475 .admitted_provider_identity()
1476 .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity))
1477 {
1478 Ok(identity) => identity,
1479 Err(error) => return CommandResult::error(error),
1480 };
1481 let subagents = config.subagents.as_ref();
1482 let provider_subagents = config.subagent_provider_config(identity);
1483 let explicit_enabled = subagents.and_then(|cfg| cfg.enabled);
1484 let raw_max_concurrent = subagents.and_then(|cfg| cfg.max_concurrent);
1485 let raw_max_depth = subagents.and_then(|cfg| cfg.max_depth);
1486 let raw_launch = subagents.and_then(|cfg| cfg.launch_concurrency);
1487 let raw_api = subagents.and_then(|cfg| cfg.api_timeout_secs);
1488 let raw_heartbeat = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
1489 let mut lines = Vec::new();
1490 lines.push(format!(
1491 "Sub-agents: {}",
1492 disabled_reason
1493 .map(|reason| format!("disabled ({reason})"))
1494 .unwrap_or_else(|| "enabled".to_string())
1495 ));
1496 lines.push(format!("Config path: {path}"));
1497 lines.push(format!(
1498 "Active provider: {} ({})",
1499 identity.key.as_str(),
1500 identity
1501 .compatibility()
1502 .map_or(identity.key.as_str(), |row| row.label)
1503 ));
1504 lines.push(format!(
1505 "subagents.enabled = {}",
1506 explicit_enabled
1507 .map(|value| value.to_string())
1508 .unwrap_or_else(|| "default true".to_string())
1509 ));
1510 lines.push(format!(
1511 "subagents.max_concurrent = {} (resolved global {}; active provider {})",
1512 option_display(raw_max_concurrent),
1513 config.max_subagents(),
1514 config.max_subagents_for_provider(identity)
1515 ));
1516 lines.push(format!(
1517 "subagents.max_depth = {} (resolved global {}; active provider {})",
1518 option_display(raw_max_depth),
1519 config.subagent_max_spawn_depth(),
1520 config.subagent_max_spawn_depth_for_provider(identity)
1521 ));
1522 lines.push(format!(
1523 "subagents.launch_concurrency = {} (resolved global {}; active provider {})",
1524 option_display(raw_launch),
1525 config.launch_concurrency(),
1526 config.launch_concurrency_for_provider(identity)
1527 ));
1528 lines.push(format!(
1529 "subagents.api_timeout_secs = {} (resolved global {}; active provider {})",
1530 option_display(raw_api),
1531 config.subagent_api_timeout_secs(),
1532 config.subagent_api_timeout_secs_for_provider(identity)
1533 ));
1534 lines.push(format!(
1535 "subagents.heartbeat_timeout_secs = {} (resolved global {}; active provider {})",
1536 option_display(raw_heartbeat),
1537 config.subagent_heartbeat_timeout_secs(),
1538 config.subagent_heartbeat_timeout_secs_for_provider(identity)
1539 ));
1540 if let Some(provider_subagents) = provider_subagents {
1541 lines.push(format!(
1542 "subagents.providers.{}.enabled = {}",
1543 identity.key.as_str(),
1544 provider_subagents
1545 .enabled
1546 .map(|value| value.to_string())
1547 .unwrap_or_else(|| "inherits".to_string())
1548 ));
1549 lines.push(format!(
1550 "subagents.providers.{}.max_concurrent = {}",
1551 identity.key.as_str(),
1552 option_display(provider_subagents.max_concurrent)
1553 ));
1554 lines.push(format!(
1555 "subagents.providers.{}.max_depth = {}",
1556 identity.key.as_str(),
1557 option_display(provider_subagents.max_depth)
1558 ));
1559 lines.push(format!(
1560 "subagents.providers.{}.launch_concurrency = {}",
1561 identity.key.as_str(),
1562 option_display(provider_subagents.launch_concurrency)
1563 ));
1564 lines.push(format!(
1565 "subagents.providers.{}.max_admitted = {}",
1566 identity.key.as_str(),
1567 option_display(provider_subagents.max_admitted)
1568 ));
1569 } else {
1570 lines.push(format!(
1571 "subagents.providers.{} = inherits global",
1572 identity.key.as_str()
1573 ));
1574 }
1575 CommandResult::message(lines.join("\n"))
1576 }
1577
1578 fn show_subagents_setting(app: &App, key: &str) -> CommandResult {
1579 let config = match load_command_config(app) {
1580 Ok(config) => config,
1581 Err(err) => return CommandResult::error(err),
1582 };
1583 let Some(key) = canonical_subagents_key(key) else {
1584 return CommandResult::error(format!(
1585 "Unknown subagents setting '{key}'. Use `/config subagents status`."
1586 ));
1587 };
1588 let identity = match app
1589 .admitted_provider_identity()
1590 .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity))
1591 {
1592 Ok(identity) => identity,
1593 Err(error) => return CommandResult::error(error),
1594 };
1595 let subagents = config.subagents.as_ref();
1596 let value = match key {
1597 "enabled" => subagents
1598 .and_then(|cfg| cfg.enabled)
1599 .map(|value| value.to_string())
1600 .unwrap_or_else(|| "default true".to_string()),
1601 "max_concurrent" => format!(
1602 "{} (resolved global {}; active provider {})",
1603 option_display(subagents.and_then(|cfg| cfg.max_concurrent)),
1604 config.max_subagents(),
1605 config.max_subagents_for_provider(identity)
1606 ),
1607 "max_depth" => format!(
1608 "{} (resolved global {}; active provider {})",
1609 option_display(subagents.and_then(|cfg| cfg.max_depth)),
1610 config.subagent_max_spawn_depth(),
1611 config.subagent_max_spawn_depth_for_provider(identity)
1612 ),
1613 "launch_concurrency" => format!(
1614 "{} (resolved global {}; active provider {})",
1615 option_display(subagents.and_then(|cfg| cfg.launch_concurrency)),
1616 config.launch_concurrency(),
1617 config.launch_concurrency_for_provider(identity)
1618 ),
1619 "api_timeout_secs" => format!(
1620 "{} (resolved global {}; active provider {})",
1621 option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)),
1622 config.subagent_api_timeout_secs(),
1623 config.subagent_api_timeout_secs_for_provider(identity)
1624 ),
1625 "heartbeat_timeout_secs" => format!(
1626 "{} (resolved global {}; active provider {})",
1627 option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)),
1628 config.subagent_heartbeat_timeout_secs(),
1629 config.subagent_heartbeat_timeout_secs_for_provider(identity)
1630 ),
1631 _ => unreachable!("canonical subagent key"),
1632 };
1633 CommandResult::message(format!("subagents.{key} = {value}"))
1634 }
1635
1636 fn option_display<T: std::fmt::Display>(value: Option<T>) -> String {
1637 value
1638 .map(|value| value.to_string())
1639 .unwrap_or_else(|| "default".to_string())
1640 }
1641
1642 fn canonical_subagents_key(key: &str) -> Option<&'static str> {
1643 let normalized = key.trim().to_ascii_lowercase();
1644 let key = normalized
1645 .strip_prefix("subagents.")
1646 .unwrap_or(normalized.as_str());
1647 match key {
1648 "enabled" | "enable" => Some("enabled"),
1649 "max_concurrent" | "max_subagents" | "concurrency" | "cap" => Some("max_concurrent"),
1650 "max_depth" | "depth" | "spawn_depth" => Some("max_depth"),
1651 "launch_concurrency" | "launches" | "launch" => Some("launch_concurrency"),
1652 "api_timeout_secs" | "api_timeout" | "step_timeout_secs" => Some("api_timeout_secs"),
1653 "heartbeat_timeout_secs" | "heartbeat_timeout" | "heartbeat" => {
1654 Some("heartbeat_timeout_secs")
1655 }
1656 _ => None,
1657 }
1658 }
1659
1660 fn set_subagents_config_value(
1661 app: &mut App,
1662 key: &str,
1663 value: &str,
1664 persist: bool,
1665 ) -> CommandResult {
1666 let Some(key) = canonical_subagents_key(key) else {
1667 return CommandResult::error(format!(
1668 "Unknown subagents setting '{key}'. Use `/config subagents status`."
1669 ));
1670 };
1671 let mut config = match load_command_config(app) {
1672 Ok(config) => config,
1673 Err(err) => return CommandResult::error(err),
1674 };
1675 let identity = match app.admitted_provider_identity().and_then(|identity| {
1676 config
1677 .verify_provider_identity(identity)
1678 .map(|()| identity.clone())
1679 }) {
1680 Ok(identity) => identity,
1681 Err(error) => return CommandResult::error(error),
1682 };
1683 let current_max_subagents = config.max_subagents() as u64;
1684 let subagents = config
1685 .subagents
1686 .get_or_insert_with(SubagentsConfig::default);
1687
1688 let mut note = None;
1689 let save_result = match key {
1690 "enabled" => {
1691 let enabled = match parse_config_bool(value) {
1692 Ok(enabled) => enabled,
1693 Err(err) => return CommandResult::error(err),
1694 };
1695 subagents.enabled = Some(enabled);
1696 if persist {
1697 Some(persist_subagents_bool_key(
1698 app.config_path.as_deref(),
1699 "enabled",
1700 enabled,
1701 ))
1702 } else {
1703 None
1704 }
1705 }
1706 "max_concurrent" => {
1707 let raw = match parse_subagents_u64(key, value) {
1708 Ok(raw) => raw,
1709 Err(err) => return CommandResult::error(err),
1710 };
1711 let clamped = raw.min(MAX_SUBAGENTS as u64);
1712 if clamped != raw {
1713 note = Some(format!("clamped from {raw} to {clamped}"));
1714 }
1715 subagents.max_concurrent = Some(clamped as usize);
1716 if persist {
1717 Some(persist_subagents_integer_key(
1718 app.config_path.as_deref(),
1719 "max_concurrent",
1720 clamped,
1721 ))
1722 } else {
1723 None
1724 }
1725 }
1726 "max_depth" => {
1727 let raw = match parse_subagents_u64(key, value) {
1728 Ok(raw) => raw,
1729 Err(err) => return CommandResult::error(err),
1730 };
1731 let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
1732 let clamped = raw.min(ceiling);
1733 if clamped != raw {
1734 note = Some(format!("clamped from {raw} to {clamped}"));
1735 }
1736 subagents.max_depth = Some(clamped as u32);
1737 if persist {
1738 Some(persist_subagents_integer_key(
1739 app.config_path.as_deref(),
1740 "max_depth",
1741 clamped,
1742 ))
1743 } else {
1744 None
1745 }
1746 }
1747 "launch_concurrency" => {
1748 let raw = match parse_subagents_u64(key, value) {
1749 Ok(raw) => raw,
1750 Err(err) => return CommandResult::error(err),
1751 };
1752 let clamped = raw.clamp(1, current_max_subagents);
1753 if clamped != raw {
1754 note = Some(format!("clamped from {raw} to {clamped}"));
1755 }
1756 subagents.launch_concurrency = Some(clamped as usize);
1757 if persist {
1758 Some(persist_subagents_integer_key(
1759 app.config_path.as_deref(),
1760 "launch_concurrency",
1761 clamped,
1762 ))
1763 } else {
1764 None
1765 }
1766 }
1767 "api_timeout_secs" => {
1768 let raw = match parse_subagents_u64(key, value) {
1769 Ok(raw) => raw,
1770 Err(err) => return CommandResult::error(err),
1771 };
1772 let stored = if raw == 0 {
1773 0
1774 } else {
1775 raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)
1776 };
1777 if stored != raw {
1778 note = Some(format!("clamped from {raw} to {stored}"));
1779 }
1780 subagents.api_timeout_secs = Some(stored);
1781 if persist {
1782 Some(persist_subagents_integer_key(
1783 app.config_path.as_deref(),
1784 "api_timeout_secs",
1785 stored,
1786 ))
1787 } else {
1788 None
1789 }
1790 }
1791 "heartbeat_timeout_secs" => {
1792 let raw = match parse_subagents_u64(key, value) {
1793 Ok(raw) => raw,
1794 Err(err) => return CommandResult::error(err),
1795 };
1796 let stored = if raw == 0 {
1797 0
1798 } else {
1799 raw.clamp(
1800 MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
1801 MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
1802 )
1803 };
1804 if stored != raw {
1805 note = Some(format!("clamped from {raw} to {stored}"));
1806 }
1807 subagents.heartbeat_timeout_secs = Some(stored);
1808 if persist {
1809 Some(persist_subagents_integer_key(
1810 app.config_path.as_deref(),
1811 "heartbeat_timeout_secs",
1812 stored,
1813 ))
1814 } else {
1815 None
1816 }
1817 }
1818 _ => unreachable!("canonical subagent key"),
1819 };
1820
1821 let save_suffix = if let Some(result) = save_result {
1822 match result {
1823 Ok(path) => format!("saved to {}", path.display()),
1824 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1825 }
1826 } else {
1827 "session only, add --save to persist".to_string()
1828 };
1829
1830 if key == "max_concurrent" {
1831 app.max_subagents = config.max_subagents_for_provider(&identity);
1832 }
1833 let display_value = subagents_config_display_value(&config, key);
1834 let note = note.map(|note| format!("; {note}")).unwrap_or_default();
1835 CommandResult::with_message_and_action(
1836 format!(
1837 "subagents.{key} = {display_value} ({save_suffix}; runtime updated for subsequent turns{note})"
1838 ),
1839 subagents_runtime_action(&identity, &config),
1840 )
1841 }
1842
1843 fn parse_subagents_u64(key: &str, value: &str) -> Result<u64, String> {
1844 value
1845 .trim()
1846 .parse::<u64>()
1847 .map_err(|_| format!("subagents.{key} must be a whole number"))
1848 }
1849
1850 fn subagents_config_display_value(config: &Config, key: &str) -> String {
1851 let subagents = config.subagents.as_ref();
1852 match key {
1853 "enabled" => subagents
1854 .and_then(|cfg| cfg.enabled)
1855 .map(|value| value.to_string())
1856 .unwrap_or_else(|| "default true".to_string()),
1857 "max_concurrent" => {
1858 if subagents.and_then(|cfg| cfg.max_concurrent) == Some(0) {
1859 "0 (disabled)".to_string()
1860 } else {
1861 config.max_subagents().to_string()
1862 }
1863 }
1864 "max_depth" => {
1865 if subagents.and_then(|cfg| cfg.max_depth) == Some(0) {
1866 "0 (agent tool disabled)".to_string()
1867 } else {
1868 config.subagent_max_spawn_depth().to_string()
1869 }
1870 }
1871 "launch_concurrency" => config.launch_concurrency().to_string(),
1872 "api_timeout_secs" => {
1873 let raw = subagents.and_then(|cfg| cfg.api_timeout_secs);
1874 if raw == Some(0) {
1875 format!("0 (default {DEFAULT_SUBAGENT_API_TIMEOUT_SECS})")
1876 } else {
1877 config.subagent_api_timeout_secs().to_string()
1878 }
1879 }
1880 "heartbeat_timeout_secs" => {
1881 let raw = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
1882 if raw == Some(0) {
1883 format!("0 (default {DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS})")
1884 } else {
1885 config.subagent_heartbeat_timeout_secs().to_string()
1886 }
1887 }
1888 _ => unreachable!("canonical subagent key"),
1889 }
1890 }
1891
1892 fn subagents_runtime_action(
1893 identity: &crate::config::ProviderIdentity,
1894 config: &Config,
1895 ) -> AppAction {
1896 let max_subagents = config
1897 .max_subagents_for_provider(identity)
1898 .clamp(1, MAX_SUBAGENTS);
1899 AppAction::UpdateSubagentRuntimeConfig {
1900 enabled: config.subagents_enabled_for_provider(identity),
1901 max_subagents,
1902 launch_concurrency: config.launch_concurrency_for_provider(identity),
1903 max_spawn_depth: config.subagent_max_spawn_depth_for_provider(identity),
1904 api_timeout_secs: config.subagent_api_timeout_secs_for_provider(identity),
1905 heartbeat_timeout_secs: config.subagent_heartbeat_timeout_secs_for_provider(identity),
1906 }
1907 }
1908
1909 /// The subject a live-route key belongs to, or `None` if the key does not touch
1910 /// the route the engine is currently acting on.
1911 ///
1912 /// This is the single list the #2982 turn lock is enforced from. It exists
1913 /// because the lock used to live in the *selectors* — the Tab cycle, the
1914 /// pickers, the hotbar — while `/set <key> <value>` and `/config <key> <value>`
1915 /// reached the same live state through a different door. A slash command is
1916 /// reachable mid-turn (the composer accepts Shift+Enter and the slash menu while
1917 /// `is_loading`), so during a running turn `/set model …` could swap the route
1918 /// out from under the engine and persist it.
1919 ///
1920 /// `default_mode` is deliberately absent: it is a restart default that
1921 /// `set_config_value` explicitly does *not* apply to the live session, so
1922 /// refusing it would lock a key that cannot affect the turn.
1923 fn live_route_setting_subject(key: &str) -> Option<MessageId> {
1924 match key {
1925 "mode" => Some(MessageId::SettingSubjectMode),
1926 // `default_model` is not merely a startup default: for the DeepSeek
1927 // routes `set_config_value` installs it as the live model.
1928 "model" | "default_model" => Some(MessageId::SettingSubjectModel),
1929 "reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking),
1930 "provider" => Some(MessageId::SettingSubjectProvider),
1931 "approval_mode" | "approval_policy" | "approval" => {
1932 Some(MessageId::SettingSubjectPermissions)
1933 }
1934 _ => None,
1935 }
1936 }
1937
1938 /// Modify a setting at runtime
1939 pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
1940 let key = key.to_lowercase();
1941 if let Some(subagent_key) = key.strip_prefix("subagents.") {
1942 return set_subagents_config_value(app, subagent_key, value, persist);
1943 }
1944 if let Some(notifications_key) = key.strip_prefix("notifications.") {
1945 return set_notifications_value(app, notifications_key, value, persist);
1946 }
1947
1948 // Refuse before *anything* — before the disk write, and before the live
1949 // `App` mutation each arm performs. Placing the check at the top is what
1950 // makes it central: every caller of this function (`/set`, `/config k v`,
1951 // the preset mirror, the schema-driven config editor, the runtime
1952 // `ConfigUpdated` event) inherits it, and none of them can half-apply.
1953 if let Some(subject) = live_route_setting_subject(key.as_str())
1954 && app.is_loading
1955 {
1956 return CommandResult::error(app.setting_locked_message(subject));
1957 }
1958
1959 match key.as_str() {
1960 "contextual_tips" => {
1961 let enabled = match parse_config_bool(value) {
1962 Ok(enabled) => enabled,
1963 Err(_) => {
1964 return CommandResult::error(
1965 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
1966 .replace("{key}", &key)
1967 .replace("{value}", value)
1968 .replace("{choices}", "on/off"),
1969 );
1970 }
1971 };
1972 // Apply the opt-out even when the settings file cannot be read
1973 // or saved. Only the existing single-key transaction may claim
1974 // persistence; a failure leaves the live preference in effect.
1975 app.set_contextual_tips_enabled(enabled);
1976 if persist && let Err(error) = persist_single_setting(&key, &enabled.to_string()) {
1977 let message = tr(app.ui_locale, MessageId::ContextualTipsNotSaved)
1978 .replace("{error}", &error.to_string());
1979 app.push_status_toast(
1980 message.clone(),
1981 crate::tui::app::StatusToastLevel::Error,
1982 Some(8_000),
1983 );
1984 return CommandResult::error(message);
1985 }
1986 let scope = if persist {
1987 MessageId::ConfigScopeSaved
1988 } else {
1989 MessageId::ConfigScopeSession
1990 };
1991 return CommandResult::message(format!(
1992 "contextual_tips = {enabled} ({})",
1993 tr(app.ui_locale, scope)
1994 ));
1995 }
1996 "telemetry" => {
1997 if !persist {
1998 return CommandResult::error(
1999 "Telemetry is a durable privacy preference. Change it in /settings or add --save.",
2000 );
2001 }
2002 let enabled = match parse_config_bool(value) {
2003 Ok(enabled) => enabled,
2004 Err(err) => return CommandResult::error(err),
2005 };
2006 let applied = crate::telemetry_notice::apply_persistent_preference(
2007 app.config_path.clone(),
2008 enabled,
2009 );
2010 let message = applied.message(app.ui_locale);
2011 return if applied.is_error() {
2012 CommandResult {
2013 message: Some(message),
2014 action: None,
2015 is_error: true,
2016 }
2017 } else {
2018 CommandResult::message(message)
2019 };
2020 }
2021 "default_model" => {
2022 let value = value.trim();
2023 let value = if value.is_empty()
2024 || matches!(
2025 value.to_ascii_lowercase().as_str(),
2026 "none" | "default" | "(default)"
2027 ) {
2028 crate::config::DEFAULT_TEXT_MODEL
2029 } else {
2030 value
2031 };
2032 if matches!(app.api_provider, ProviderKind::Deepseek) {
2033 return set_config_value(app, "model", value, persist);
2034 }
2035 if !persist {
2036 return CommandResult::error(format!(
2037 "default_model is the DeepSeek startup fallback and cannot change the active {} session. Use /model for the current provider, or add --save to change only future DeepSeek sessions.",
2038 app.api_provider.as_str()
2039 ));
2040 }
2041 let saved = match load_command_config(app) {
2042 Ok(config) => config,
2043 Err(err) => return CommandResult::error(err),
2044 };
2045 let identity = match saved.builtin_provider_identity(ProviderKind::Deepseek) {
2046 Ok(identity) => identity,
2047 Err(error) => return CommandResult::error(error),
2048 };
2049 let model = if value.eq_ignore_ascii_case("auto") {
2050 "auto".to_string()
2051 } else {
2052 // Route-aware, matching POST /v1/config: a custom DeepSeek
2053 // endpoint owns its model namespace, so an id declared for the
2054 // saved DeepSeek route validates verbatim before the catalog
2055 // normalization runs.
2056 if crate::provider_lake::configured_model_for_route(
2057 &saved,
2058 ProviderKind::Deepseek,
2059 identity.key.as_str(),
2060 &saved.base_url_for_route(&identity),
2061 value.trim(),
2062 )
2063 .is_some()
2064 {
2065 value.trim().to_string()
2066 } else {
2067 let Some(model) =
2068 normalize_model_name_for_provider(ProviderKind::Deepseek, value)
2069 else {
2070 return CommandResult::error(format!("Invalid DeepSeek model '{value}'."));
2071 };
2072 if let Err(error) = validate_route(ProviderKind::Deepseek, &model) {
2073 return CommandResult::error(error);
2074 }
2075 model
2076 }
2077 };
2078 return match crate::config_persistence::persist_provider_model_key(
2079 app.config_path.as_deref(),
2080 &identity,
2081 &model,
2082 ) {
2083 Ok(path) => CommandResult::message(format!(
2084 "default_model = {model} (saved to {}); DeepSeek fallback only — active {}/{} is unchanged",
2085 path.display(),
2086 app.api_provider.as_str(),
2087 app.model_display_label()
2088 )),
2089 Err(error) => CommandResult::error(format!("Failed to save model: {error}")),
2090 };
2091 }
2092 "model" => {
2093 // Route-aware: a custom DeepSeek (or other) endpoint owns its model
2094 // namespace. Provider-only normalization would reject a non-DeepSeek
2095 // id that the live session is already allowed to use via `/model`.
2096 // OpenCode Go stays protocol-strict even on a custom host.
2097 let auto_select = value.trim().eq_ignore_ascii_case("auto");
2098 let model = if auto_select {
2099 "auto".to_string()
2100 } else if app.api_provider == ProviderKind::OpencodeGo {
2101 let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else {
2102 return CommandResult::error(format!(
2103 "Invalid model '{value}' for provider {}.",
2104 app.api_provider.as_str()
2105 ));
2106 };
2107 if let Err(reason) = validate_route(app.api_provider, &model) {
2108 return CommandResult::error(reason);
2109 }
2110 model
2111 } else if app.accepts_custom_model_ids()
2112 || (app.api_provider != ProviderKind::OpenaiCodex
2113 && app.configured_models.iter().any(|row| {
2114 row.id == value.trim()
2115 && row.matches_route(
2116 app.provider_identity_for_persistence(),
2117 &app.active_route_base_url,
2118 )
2119 }))
2120 {
2121 let Some(model) = normalize_custom_model_id(value) else {
2122 return CommandResult::error(format!(
2123 "Invalid model '{value}' for provider {}.",
2124 app.api_provider.as_str()
2125 ));
2126 };
2127 model
2128 } else {
2129 let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else {
2130 return CommandResult::error(format!(
2131 "Invalid model '{value}' for provider {}.",
2132 app.api_provider.as_str()
2133 ));
2134 };
2135 if let Err(reason) = validate_route(app.api_provider, &model) {
2136 return CommandResult::error(reason);
2137 }
2138 model
2139 };
2140 let saved = if persist {
2141 match crate::config_persistence::persist_provider_selection(
2142 app.config_path.as_deref(),
2143 match app.admitted_provider_identity() {
2144 Ok(identity) => identity,
2145 Err(error) => return CommandResult::error(error),
2146 },
2147 Some(&model),
2148 ) {
2149 Ok((path, _)) => Some(path),
2150 Err(error) => {
2151 return CommandResult::error(format!("Failed to save model: {error}"));
2152 }
2153 }
2154 } else {
2155 None
2156 };
2157 app.set_model_selection(model.clone());
2158 app.update_model_compaction_budget();
2159 app.session.last_prompt_tokens = None;
2160 app.session.last_completion_tokens = None;
2161 let mut message = if model == "auto" {
2162 format!(
2163 "model = auto (auto-select model per turn; thinking = {})",
2164 app.reasoning_effort_display_label()
2165 )
2166 } else {
2167 format!("model = {model}")
2168 };
2169 if let Some(path) = saved {
2170 message.push_str(&format!(" (saved to {})", path.display()));
2171 }
2172 return CommandResult::with_message_and_action(
2173 message,
2174 AppAction::UpdateCompaction(app.compaction_config()),
2175 );
2176 }
2177 "provider" => {
2178 let value = value.trim();
2179 let Some(row) = codewhale_config::descriptors::compatibility_for_selector(value) else {
2180 return CommandResult::error(format!(
2181 "Unknown provider '{value}'. Use: {}.",
2182 ProviderKind::names_hint()
2183 ));
2184 };
2185 if app
2186 .provider_identity
2187 .as_ref()
2188 .is_some_and(|identity| identity.key.as_str() == row.id)
2189 {
2190 return CommandResult::message(format!("provider = {}", row.id));
2191 }
2192 return CommandResult::with_message_and_action(
2193 format!("provider = {}", row.id),
2194 AppAction::SwitchProvider {
2195 provider: row.id.into(),
2196 model: None,
2197 },
2198 );
2199 }
2200 "approval_mode" | "approval_policy" | "approval" => {
2201 let use_tui_default = matches!(
2202 value
2203 .trim()
2204 .to_ascii_lowercase()
2205 .replace([' ', '_'], "-")
2206 .as_str(),
2207 "default" | "tui-default" | "use-tui-default"
2208 );
2209 if use_tui_default {
2210 if !persist {
2211 return CommandResult::error(
2212 "Removing the config approval override requires --save.",
2213 );
2214 }
2215 let control = match load_command_config(app) {
2216 Ok(config) => config.approval_policy_control(
2217 app.config_path.as_deref(),
2218 app.config_profile.as_deref(),
2219 &app.workspace,
2220 ),
2221 Err(err) => return CommandResult::error(err),
2222 };
2223 if !matches!(
2224 control,
2225 crate::config::ApprovalPolicyControl::RootConfig
2226 | crate::config::ApprovalPolicyControl::Unset
2227 ) {
2228 return CommandResult::error(format!(
2229 "Approval posture is controlled by {}; change that source first.",
2230 control.label()
2231 ));
2232 }
2233 return match persist_unset_root_key(app.config_path.as_deref(), "approval_policy") {
2234 Ok(path) => {
2235 let saved_mode = Settings::load_persisted()
2236 .ok()
2237 .and_then(|settings| settings.permission_posture)
2238 .as_deref()
2239 .and_then(ApprovalMode::from_config_value)
2240 .unwrap_or(ApprovalMode::Suggest);
2241 app.set_agent_approval_posture(saved_mode);
2242 app.clear_saved_approval_policy_lock();
2243 CommandResult::with_message_and_action(
2244 format!(
2245 "approval_policy removed from {}; new sessions use the TUI {} default",
2246 path.display(),
2247 saved_mode.permission_chip_label()
2248 ),
2249 AppAction::ApprovalPolicyPersisted { policy: None },
2250 )
2251 }
2252 Err(err) => CommandResult::error(format!("Failed to save: {err}")),
2253 };
2254 }
2255 let control = match load_command_config(app) {
2256 Ok(config) => config.approval_policy_control(
2257 app.config_path.as_deref(),
2258 app.config_profile.as_deref(),
2259 &app.workspace,
2260 ),
2261 Err(err) => return CommandResult::error(err),
2262 };
2263 let control_allows_change = if persist {
2264 control.editable_root()
2265 } else {
2266 matches!(control, crate::config::ApprovalPolicyControl::Unset)
2267 };
2268 if !control_allows_change {
2269 return CommandResult::error(format!(
2270 "Approval posture is controlled by {}; {}.",
2271 control.label(),
2272 if matches!(control, crate::config::ApprovalPolicyControl::RootConfig) {
2273 "save a new config value or choose Use TUI permission default"
2274 } else {
2275 "change that source first"
2276 }
2277 ));
2278 }
2279 let mode = ApprovalMode::from_config_value(value);
2280 return match mode {
2281 Some(ApprovalMode::Bypass)
2282 if persist
2283 && matches!(control, crate::config::ApprovalPolicyControl::RootConfig) =>
2284 {
2285 match app.adopt_root_approval_posture(ApprovalMode::Bypass) {
2286 Ok(()) => CommandResult::with_message_and_action(
2287 "approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)",
2288 AppAction::ApprovalPolicyPersisted { policy: None },
2289 ),
2290 Err(reason) => {
2291 CommandResult::error(format!("Failed to save Full Access: {reason}"))
2292 }
2293 }
2294 }
2295 Some(ApprovalMode::Bypass) if persist => CommandResult::error(
2296 "Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.",
2297 ),
2298 Some(m) => {
2299 if persist {
2300 let saved = approval_mode_config_value(m);
2301 match persist_root_string_key(
2302 app.config_path.as_deref(),
2303 "approval_policy",
2304 saved,
2305 ) {
2306 Ok(path) => {
2307 app.set_agent_approval_posture(m);
2308 app.mark_approval_policy_locked();
2309 CommandResult::with_message_and_action(
2310 format!(
2311 "approval_mode = {} (saved to {} as approval_policy = \"{}\")",
2312 m.permission_chip_label(),
2313 path.display(),
2314 saved
2315 ),
2316 AppAction::ApprovalPolicyPersisted {
2317 policy: Some(saved.to_string()),
2318 },
2319 )
2320 }
2321 Err(err) => CommandResult::error(format!("Failed to save: {err}")),
2322 }
2323 } else {
2324 app.set_agent_approval_posture(m);
2325 CommandResult::with_message_and_action(
2326 format!(
2327 "approval_mode = {} (session only, add --save to persist)",
2328 m.permission_chip_label()
2329 ),
2330 AppAction::ModeChanged(app.mode),
2331 )
2332 }
2333 }
2334 None => CommandResult::error(
2335 "Invalid approval_mode. Use: auto-review/auto, ask/suggest/on-request, full-access, never/deny",
2336 ),
2337 };
2338 }
2339 "allow_shell" | "shell" | "exec_shell" => {
2340 let control = match load_command_config(app) {
2341 Ok(config) => config.allow_shell_control(
2342 app.config_path.as_deref(),
2343 app.config_profile.as_deref(),
2344 &app.workspace,
2345 ),
2346 Err(err) => return CommandResult::error(err),
2347 };
2348 if !control.editable_root() {
2349 return CommandResult::error(format!(
2350 "Shell access is controlled by {}; change that source first.",
2351 control.label()
2352 ));
2353 }
2354 let enabled = match parse_config_bool(value) {
2355 Ok(enabled) => enabled,
2356 Err(err) => return CommandResult::error(err),
2357 };
2358 let suffix = if persist {
2359 match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) {
2360 Ok(path) => format!(" (saved to {})", path.display()),
2361 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
2362 }
2363 } else {
2364 " (session only, add --save to persist)".to_string()
2365 };
2366 app.set_agent_shell_access(enabled);
2367 let mode_hint = if enabled {
2368 " Act mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves."
2369 } else {
2370 " Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`."
2371 };
2372 return CommandResult::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}"));
2373 }
2374 "mcp_config_path" | "mcp" => {
2375 if value.trim().is_empty() {
2376 return CommandResult::error("mcp_config_path cannot be empty");
2377 }
2378 let next_path = PathBuf::from(expand_tilde(value));
2379 let path_changed = next_path != app.mcp_config_path;
2380 let reload_note = if path_changed {
2381 "; run /mcp reload to rebuild the live tool pool"
2382 } else {
2383 ""
2384 };
2385 // Persist before touching live state (C01-08): a failed save
2386 // must leave the session exactly as it was, not report failure
2387 // over a path and reload flag that already moved.
2388 let saved_to = if persist {
2389 match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value)
2390 {
2391 Ok(path) => Some(path),
2392 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
2393 }
2394 } else {
2395 None
2396 };
2397 app.mcp_config_path = next_path;
2398 if path_changed {
2399 app.mcp_reload_required = true;
2400 }
2401 let message = match saved_to {
2402 Some(path) => format!(
2403 "mcp_config_path = {} (saved to {}){}",
2404 app.mcp_config_path.display(),
2405 path.display(),
2406 reload_note
2407 ),
2408 None => format!(
2409 "mcp_config_path = {} (session only){}",
2410 app.mcp_config_path.display(),
2411 reload_note
2412 ),
2413 };
2414 return CommandResult::message(message);
2415 }
2416 "title" | "window_title" | "tab_title" => {
2417 // Keep the config setter under the same terminal-control and
2418 // bidi/zero-width policy as `/title` and `/rename`. Persist the
2419 // normalized value too, so a restart cannot reintroduce bytes the
2420 // live session already discarded.
2421 let sanitized = crate::session_manager::sanitize_session_title(value);
2422 let value = sanitized.trim();
2423 if value.is_empty() {
2424 return CommandResult::error(
2425 "title cannot be empty; use /title off to clear a session title",
2426 );
2427 }
2428 if value.chars().count() > 100 {
2429 return CommandResult::error("Title too long (max 100 characters)");
2430 }
2431 let suffix = if persist {
2432 match persist_root_string_key(app.config_path.as_deref(), "title", value) {
2433 Ok(path) => format!(" (saved to {})", path.display()),
2434 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
2435 }
2436 } else {
2437 " (session only, add --save to persist)".to_string()
2438 };
2439 app.title_default = Some(value.to_string());
2440 app.needs_redraw = true;
2441 return CommandResult::message(format!(
2442 "title = {value}{suffix} — terminal window titles now read [\"{value}\"] … until /title overrides this session"
2443 ));
2444 }
2445 // `base_url` is the older spelling. It used to write a top-level key
2446 // that every DeepSeek-family route inherited; it now writes the
2447 // active route's own `[providers.<name>]` table like `provider_url`
2448 // (#6394).
2449 url_key @ ("base_url" | "provider_url" | "provider_base_url" | "endpoint") => {
2450 let value = match resolve_provider_url_value(app.api_provider, value) {
2451 Ok(value) => value,
2452 Err(err) => return CommandResult::error(err),
2453 };
2454 if persist {
2455 match crate::config_persistence::persist_route_base_url(
2456 app.config_path.as_deref(),
2457 match app.admitted_provider_identity() {
2458 Ok(identity) => identity,
2459 Err(error) => return CommandResult::error(error),
2460 },
2461 &value,
2462 ) {
2463 Ok(path) => {
2464 return CommandResult::message(format!(
2465 "{url_key} = {value} for {} (saved to {}; restart required)",
2466 app.api_provider.as_str(),
2467 path.display()
2468 ));
2469 }
2470 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
2471 }
2472 }
2473 return CommandResult::error(format!(
2474 "{url_key} must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving."
2475 ));
2476 }
2477 // The two bottom-chrome rows' size presets (`tui.posture_bar`,
2478 // `tui.metrics_line`, #5950). Live on the next frame; `--save`
2479 // writes the owning `[tui]` table. `/statusline` composes what is in a row;
2480 // this only decides whether and how much of it paints.
2481 row_key @ ("posture_bar" | "metrics_line") => {
2482 let Some(preset) = crate::config::ChromeRowPreset::from_setting(value) else {
2483 return CommandResult::error(
2484 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
2485 .replace("{key}", row_key)
2486 .replace("{value}", value)
2487 .replace(
2488 "{choices}",
2489 &crate::config::ChromeRowPreset::SETTINGS.join(", "),
2490 ),
2491 );
2492 };
2493 let value = preset.as_setting();
2494 let scope = if persist {
2495 let saved = crate::config_persistence::config_toml_path(app.config_path.as_deref())
2496 .and_then(|path| {
2497 crate::config_persistence::mutate_config_document(&path, |doc| {
2498 // Profiles replace the whole TUI table on load. Edit its
2499 // existing owner without creating an empty override that
2500 // would reset the other inherited display settings.
2501 let mut segments = Vec::new();
2502 if let Some(profile) = app.config_profile.as_deref() {
2503 let table = doc
2504 .get("profiles")
2505 .and_then(|v| v.get(profile))
2506 .and_then(toml_edit::Item::as_table_like)
2507 .ok_or_else(|| {
2508 anyhow::anyhow!("active profile is missing or malformed")
2509 })?;
2510 if table.contains_key("tui") {
2511 segments.extend(["profiles", profile]);
2512 }
2513 }
2514 segments.extend(["tui", row_key]);
2515 crate::config_persistence::set_document_value(doc, &segments, value)
2516 })?;
2517 Ok(path)
2518 });
2519 match saved {
2520 Ok(path) => format!(
2521 "{} {}",
2522 tr(app.ui_locale, MessageId::ConfigScopeSaved),
2523 path.display()
2524 ),
2525 Err(error) => {
2526 return CommandResult::error(
2527 tr(app.ui_locale, MessageId::StartupDefaultNotSaved)
2528 .replace("{setting}", row_key)
2529 .replace("{error}", &error.to_string()),
2530 );
2531 }
2532 }
2533 } else {
2534 tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned()
2535 };
2536 if row_key == "posture_bar" {
2537 app.posture_bar = preset;
2538 } else {
2539 app.metrics_line = preset;
2540 }
2541 app.needs_redraw = true;
2542 return CommandResult::message(format!("{row_key} = {value} ({scope})"));
2543 }
2544 "stream_chunk_timeout_secs" => {
2545 let raw = match value.trim().parse::<u64>() {
2546 Ok(value) => value,
2547 Err(_) => {
2548 return CommandResult::error(
2549 "stream_chunk_timeout_secs must be a whole number",
2550 );
2551 }
2552 };
2553 if raw != 0
2554 && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw)
2555 {
2556 return CommandResult::error(format!(
2557 "stream_chunk_timeout_secs must be 0 or {MIN_STREAM_CHUNK_TIMEOUT_SECS}..={MAX_STREAM_CHUNK_TIMEOUT_SECS}"
2558 ));
2559 }
2560 let resolved = if raw == 0 {
2561 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
2562 } else {
2563 raw
2564 };
2565 let value_label = stream_chunk_timeout_value_label(raw, resolved);
2566 if persist {
2567 match persist_table_value_key(
2568 app.config_path.as_deref(),
2569 "stream",
2570 "chunk_timeout_secs",
2571 (raw as i64).into(), // validated above: at most 3600
2572 ) {
2573 Ok(path) => {
2574 // Live state moves only with the engine action and
2575 // only after the save landed (C01-08).
2576 app.stream_chunk_timeout_secs = resolved;
2577 return CommandResult::with_message_and_action(
2578 format!(
2579 "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)",
2580 path.display()
2581 ),
2582 AppAction::UpdateStreamChunkTimeout(resolved),
2583 );
2584 }
2585 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
2586 }
2587 }
2588 app.stream_chunk_timeout_secs = resolved;
2589 return CommandResult::with_message_and_action(
2590 format!(
2591 "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)"
2592 ),
2593 AppAction::UpdateStreamChunkTimeout(resolved),
2594 );
2595 }
2596 "search" | "search.provider" | "search_provider" => {
2597 return set_search_provider(app, value, persist);
2598 }
2599 "prompt_suggestion" => return set_prompt_suggestion(app, value, persist),
2600 "notifications" => return notifications_config_command(app, value),
2601 _ => {}
2602 }
2603
2604 // This copy exists to validate the value and to project it onto live `App`
2605 // state. It is deliberately *not* what gets saved: see
2606 // [`persist_single_setting`].
2607 let mut settings = match Settings::load_persisted() {
2608 Ok(s) => s,
2609 Err(e) if !persist => {
2610 app.status_message = Some(format!(
2611 "Settings unavailable; applying session-only override ({e})"
2612 ));
2613 Settings::default()
2614 }
2615 Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
2616 };
2617
2618 if let Err(e) = settings.set(&key, value) {
2619 return CommandResult::error(format!("{e}"));
2620 }
2621 // Runtime/environment constraints are an effective projection, not saved
2622 // preferences. Keep the persisted copy pristine so NO_ANIMATIONS or a
2623 // terminal quirk cannot become permanent during an unrelated edit.
2624 let mut effective_settings = settings.clone();
2625 effective_settings.apply_env_overrides();
2626
2627 let mut action = None;
2628 match key.as_str() {
2629 "auto_compact" | "compact" => {
2630 app.auto_compact = settings.auto_compact;
2631 app.auto_compact_user_configured = true;
2632 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
2633 }
2634 "auto_compact_threshold" | "auto_compact_threshold_percent" => {
2635 app.auto_compact = true;
2636 app.auto_compact_user_configured = true;
2637 app.auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
2638 app.update_model_compaction_budget();
2639 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
2640 }
2641 "calm_mode" | "calm" => {
2642 app.calm_mode = settings.calm_mode;
2643 app.mark_history_updated();
2644 }
2645 "low_motion" | "motion" => {
2646 app.low_motion = effective_settings.low_motion;
2647 app.needs_redraw = true;
2648 }
2649 "fancy_animations" | "fancy" | "animations" => {
2650 app.fancy_animations = effective_settings.fancy_animations;
2651 app.needs_redraw = true;
2652 }
2653 "focus_texture" | "texture" => {
2654 app.focus_texture =
2655 crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture)
2656 .unwrap_or_default();
2657 app.needs_redraw = true;
2658 }
2659 "work_surface_placement" | "work_surface" | "work_rail" => {
2660 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::parse(
2661 &settings.work_surface_placement,
2662 );
2663 app.work_surface.focused = false;
2664 app.work_surface.last_area = None;
2665 app.needs_redraw = true;
2666 }
2667 "rail_panel" | "rail" => {
2668 app.work_surface.panel =
2669 crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
2670 app.needs_redraw = true;
2671 }
2672 "work_surface_top_height" | "work_top_height" => {
2673 app.work_surface.top_height = settings.work_surface_top_height;
2674 app.needs_redraw = true;
2675 }
2676 "work_surface_side_width" | "work_side_width" => {
2677 app.work_surface.side_width = settings.work_surface_side_width;
2678 app.needs_redraw = true;
2679 }
2680 "bracketed_paste" | "paste" => {
2681 app.use_bracketed_paste = settings.bracketed_paste;
2682 app.needs_redraw = true;
2683 }
2684 "status_indicator" | "indicator" => {
2685 app.status_indicator = settings.status_indicator.clone();
2686 app.needs_redraw = true;
2687 }
2688 "synchronized_output" | "sync_output" | "sync" => {
2689 app.synchronized_output_enabled = effective_settings.synchronized_output_enabled();
2690 app.needs_redraw = true;
2691 }
2692 "show_thinking" | "thinking" => {
2693 app.show_thinking = settings.show_thinking;
2694 app.mark_history_updated();
2695 }
2696 "thinking_default_expanded" | "thinking_expanded" => {
2697 app.thinking_default_expanded = settings.thinking_default_expanded;
2698 app.mark_history_updated();
2699 }
2700 "thinking_preview_lines" | "thinking_preview" => {
2701 app.thinking_preview_lines = settings.thinking_preview_lines;
2702 app.mark_history_updated();
2703 }
2704 "help_expand_groups" | "help_expanded" => {
2705 app.help_expand_groups = settings.help_expand_groups;
2706 app.needs_redraw = true;
2707 }
2708 "pin_last_prompt" | "pin_prompt" => {
2709 app.pin_last_prompt = settings.pin_last_prompt;
2710 app.needs_redraw = true;
2711 }
2712 "thinking_highlight" | "reasoning_highlight" => {
2713 app.thinking_highlight = settings.thinking_highlight;
2714 app.mark_history_updated();
2715 }
2716 "show_tool_details" | "tool_details" => {
2717 app.show_tool_details = settings.show_tool_details;
2718 app.mark_history_updated();
2719 }
2720 "inline_diffs" | "inline_diff" | "diffs" => {
2721 app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs);
2722 app.mark_history_updated();
2723 app.needs_redraw = true;
2724 }
2725 "locale" | "language" => {
2726 app.ui_locale = resolve_locale(&settings.locale);
2727 app.mark_history_updated();
2728 app.needs_redraw = true;
2729 }
2730 "theme" | "ui_theme" | "background_color" | "background" | "bg" => {
2731 // Theme previews reload persisted settings for each cursor move.
2732 // Keep a session-only background overlay live unless this command
2733 // is itself updating (or clearing) the background.
2734 let background_color_override = if matches!(key.as_str(), "theme" | "ui_theme") {
2735 app.background_color_override
2736 } else {
2737 settings
2738 .background_color
2739 .as_deref()
2740 .and_then(codewhale_palette::parse_hex_rgb_color)
2741 };
2742 let background_setting =
2743 background_color_override.and_then(codewhale_palette::hex_rgb_string);
2744 let (theme_name, theme_id, ui_theme) = match codewhale_palette::resolve_theme_setting(
2745 &settings.theme,
2746 background_setting.as_deref(),
2747 ) {
2748 Ok(resolved) => resolved,
2749 Err(error) => {
2750 return CommandResult::error(format!("Failed to apply theme: {error}"));
2751 }
2752 };
2753 app.background_color_override = background_color_override;
2754 app.theme_id = theme_id;
2755 app.theme_name = theme_name;
2756 app.ui_theme = ui_theme;
2757 app.needs_redraw = true;
2758 }
2759 "cost_currency" | "currency" => {
2760 app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
2761 .unwrap_or(crate::pricing::CostCurrency::Usd);
2762 app.needs_redraw = true;
2763 }
2764 key @ ("mini_window.keep_header"
2765 | "mini_window.keep_input"
2766 | "mini_window.keep_todo"
2767 | "mini_window.keep_sidebar"
2768 | "mini_window.keep_footer") => {
2769 let field = key.strip_prefix("mini_window.").unwrap_or(key);
2770 let value = match parse_config_bool(value) {
2771 Ok(value) => value,
2772 Err(err) => return CommandResult::error(err),
2773 };
2774 // Persist first: a failed save leaves the live layout as it was
2775 // (C01-08).
2776 if persist
2777 && let Err(err) = crate::config_persistence::persist_mini_window_bool_key(
2778 app.config_path.as_deref(),
2779 field,
2780 value,
2781 )
2782 {
2783 return CommandResult::error(format!("Failed to persist: {err}"));
2784 }
2785 match field {
2786 "keep_header" => app.mini_window.keep_header = value,
2787 "keep_input" => app.mini_window.keep_input = value,
2788 "keep_todo" => app.mini_window.keep_todo = value,
2789 "keep_sidebar" => app.mini_window.keep_sidebar = value,
2790 "keep_footer" => app.mini_window.keep_footer = value,
2791 _ => unreachable!("mini_window field matched above"),
2792 }
2793 app.needs_redraw = true;
2794 }
2795 "composer_density" | "composer" => {
2796 app.composer_density =
2797 crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
2798 app.needs_redraw = true;
2799 }
2800 "composer_border" | "border" => {
2801 app.composer_border = settings.composer_border;
2802 app.needs_redraw = true;
2803 }
2804 "composer_multiline_mode" | "multiline_mode" | "multiline" => {
2805 app.composer_multiline_mode = settings.composer_multiline_mode;
2806 app.needs_redraw = true;
2807 }
2808 "composer_vim_mode" | "vim_mode" | "vim" => {
2809 app.composer.vim_enabled = settings.composer_vim_mode == "vim";
2810 app.composer.vim_mode = if app.composer.vim_enabled {
2811 VimMode::Normal
2812 } else {
2813 VimMode::Insert
2814 };
2815 app.composer.vim_pending_d = false;
2816 app.needs_redraw = true;
2817 }
2818 "paste_burst_detection" | "paste_burst" => {
2819 app.use_paste_burst_detection = settings.paste_burst_detection;
2820 if !app.use_paste_burst_detection {
2821 app.paste_burst.clear_after_explicit_paste();
2822 }
2823 }
2824 "mention_menu_limit" | "mention_limit" => {
2825 app.mention_menu_limit = settings.mention_menu_limit;
2826 app.composer.mention_completion_cache = None;
2827 app.composer.mention_discovery.invalidate();
2828 app.needs_redraw = true;
2829 }
2830 "mention_menu_behavior" | "mention_behavior" | "mention_menu" => {
2831 app.mention_menu_behavior = settings.mention_menu_behavior.clone();
2832 app.composer.mention_completion_cache = None;
2833 app.composer.mention_discovery.invalidate();
2834 app.needs_redraw = true;
2835 }
2836 "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
2837 app.mention_walk_depth = settings.mention_walk_depth;
2838 app.composer.mention_completion_cache = None;
2839 app.composer.mention_discovery.invalidate();
2840 app.needs_redraw = true;
2841 }
2842 "workspace_follow_symlinks" | "follow_symlinks" => {
2843 // Persist first: a failed save leaves the live value as it was
2844 // (C01-08).
2845 if persist && let Err(e) = persist_single_setting(&key, value) {
2846 return CommandResult::error(format!("Failed to save: {e}"));
2847 }
2848 app.workspace_follow_symlinks = settings.workspace_follow_symlinks;
2849 app.composer.mention_completion_cache = None;
2850 app.composer.mention_discovery.invalidate();
2851 app.needs_redraw = true;
2852 // Engine tools use EngineConfig which is fixed at startup
2853 return CommandResult::message(if persist {
2854 format!(
2855 "workspace_follow_symlinks = {} (saved; restart required for engine tools)",
2856 settings.workspace_follow_symlinks
2857 )
2858 } else {
2859 format!(
2860 "workspace_follow_symlinks = {} (session only for UI; restart required for engine tools)",
2861 settings.workspace_follow_symlinks
2862 )
2863 });
2864 }
2865 "transcript_spacing" | "spacing" => {
2866 app.transcript_spacing =
2867 crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
2868 app.mark_history_updated();
2869 }
2870 "tool_collapse" | "tool_collapse_mode" | "collapse" => {
2871 app.tool_collapse_mode =
2872 crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode);
2873 app.expanded_tool_runs.clear();
2874 app.mark_history_updated();
2875 }
2876 // `default_mode` is a restart default, not a live mode switch. The
2877 // `/mode` command owns synchronized session transitions.
2878 "default_mode" => {}
2879 "mode" => {
2880 let mode = AppMode::from_setting(&settings.default_mode);
2881 app.set_mode(mode);
2882 action = Some(AppAction::ModeChanged(mode));
2883 }
2884 "max_history" | "history" => {
2885 app.max_input_history = settings.max_input_history;
2886 }
2887 "reasoning_effort" | "effort" => {
2888 app.reasoning_effort_preference = settings
2889 .reasoning_effort
2890 .as_deref()
2891 .map(ReasoningEffort::from_setting);
2892 app.reasoning_effort = app.reasoning_effort_preference.map_or_else(
2893 || {
2894 if app.auto_model {
2895 ReasoningEffort::Auto
2896 } else {
2897 ReasoningEffort::default()
2898 }
2899 },
2900 |requested| {
2901 if app.auto_model {
2902 requested
2903 } else {
2904 requested.normalize_for_provider(app.api_provider)
2905 }
2906 },
2907 );
2908 app.invalidate_route_receipts_for_reasoning_change();
2909 app.update_model_compaction_budget();
2910 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
2911 }
2912 "context_panel" | "context" | "session_panel" => {
2913 app.context_panel = settings.context_panel;
2914 app.needs_redraw = true;
2915 }
2916 "sessions_rail" | "sessions_panel" | "session_rail" => {
2917 app.sessions_rail = settings.sessions_rail;
2918 app.needs_redraw = true;
2919 }
2920 _ => {}
2921 }
2922
2923 let display_value = match key.as_str() {
2924 "default_mode" | "mode" => settings.default_mode.clone(),
2925 "cost_currency" | "currency" => settings.cost_currency.clone(),
2926 "theme" | "ui_theme" => settings.theme.clone(),
2927 "synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(),
2928 "background_color" | "background" | "bg" => settings
2929 .background_color
2930 .clone()
2931 .unwrap_or_else(|| "default".to_string()),
2932 "reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else(
2933 || "config/default".to_string(),
2934 |value| {
2935 ReasoningEffort::from_setting_for_provider(value, app.api_provider)
2936 .as_setting_for_provider(app.api_provider)
2937 .to_string()
2938 },
2939 ),
2940 "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(),
2941 "composer_multiline_mode" | "multiline_mode" | "multiline" => {
2942 settings.composer_multiline_mode.to_string()
2943 }
2944 "low_motion" | "motion" => settings.low_motion.to_string(),
2945 "fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(),
2946 _ => value.to_string(),
2947 };
2948
2949 let message = if persist {
2950 if let Err(e) = persist_single_setting(&key, value) {
2951 // C01-08: the projection above already changed live state (and
2952 // some arms validate only while projecting, so saving first could
2953 // persist a value that never applied). Report exactly that partial
2954 // effect, and keep the action so the engine matches the UI instead
2955 // of silently diverging from it.
2956 return CommandResult {
2957 message: Some(format!(
2958 "Error: {key} = {display_value} applies to this session only; saving failed: {e}"
2959 )),
2960 action,
2961 is_error: true,
2962 };
2963 }
2964 format!("{key} = {display_value} (saved)")
2965 } else {
2966 format!("{key} = {display_value} (session only, add --save to persist)")
2967 };
2968 CommandResult {
2969 message: Some(message),
2970 action,
2971 is_error: false,
2972 }
2973 }
2974
2975 /// Persist exactly the one key `/set --save` changed.
2976 ///
2977 /// `/set` loads a `Settings` copy up front to validate the value and to project
2978 /// it onto live `App` state, and a lot of `App` mutation happens in between. That
2979 /// copy is a stale snapshot by the time we get here, so saving *it* would write
2980 /// back every other field as it looked before — reverting any mode, thinking,
2981 /// model, or permission write that landed in the meantime. Re-applying the single
2982 /// key inside [`Settings::transact`] persists the user's actual edit and nothing
2983 /// else. `Settings::set` is the same normalizer the copy above already accepted
2984 /// the value through, so this cannot fail for a value that validated.
2985 fn persist_single_setting(key: &str, value: &str) -> anyhow::Result<()> {
2986 Settings::transact(|settings| settings.set(key, value))
2987 }
2988
2989 /// Select the TUI operating mode.
2990 pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
2991 let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else {
2992 return CommandResult::action(AppAction::OpenModePicker);
2993 };
2994 // The legacy YOLO spellings are a one-way permission shorthand, not a
2995 // mode: route them to the full-access compat path before parse folds
2996 // them to Act.
2997 if AppMode::is_legacy_bypass_alias(arg) {
2998 let (message, changed) = switch_yolo_compat_with_status(app);
2999 if changed {
3000 CommandResult::with_message_and_action(message, AppAction::ModeChanged(app.mode))
3001 } else {
3002 CommandResult::message(message)
3003 }
3004 } else {
3005 mode_selection(app, arg)
3006 }
3007 }
3008
3009 /// `/mode <mode>` for the real modes (Plan/Act/Operate).
3010 fn mode_selection(app: &mut App, arg: &str) -> CommandResult {
3011 match AppMode::parse(arg) {
3012 Some(mode) => {
3013 let (message, changed) = switch_mode_with_status(app, mode);
3014 if changed {
3015 CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode))
3016 } else {
3017 CommandResult::message(message)
3018 }
3019 }
3020 None => CommandResult::error("Usage: /mode [act|agent|plan|operate|1|2|3]"),
3021 }
3022 }
3023
3024 pub fn switch_mode(app: &mut App, mode: AppMode) -> String {
3025 switch_mode_with_status(app, mode).0
3026 }
3027
3028 /// Returns the user-facing sentence and whether live mode moved (the caller
3029 /// emits `AppAction::ModeChanged` only for the latter).
3030 ///
3031 /// The three outcomes read differently on purpose. Before the typed
3032 /// [`SettingSelection`], a refusal and a same-mode selection that *did* persist
3033 /// the startup default both came back as "Already in X mode." — so the one case
3034 /// where `/mode` had written something looked exactly like the case where it had
3035 /// written nothing.
3036 fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
3037 match app.select_mode(mode) {
3038 SettingSelection::Changed => (format!("Switched to {} mode.", mode.display_name()), true),
3039 SettingSelection::PersistedSame => (app.mode_startup_default_receipt(mode), false),
3040 SettingSelection::Refused => (
3041 app.setting_locked_message(MessageId::SettingSubjectMode),
3042 false,
3043 ),
3044 }
3045 }
3046
3047 /// Status for the legacy YOLO alias: user-facing copy says Act, because the
3048 /// alias is invisible Act + Full Access.
3049 fn switch_yolo_compat_with_status(app: &mut App) -> (String, bool) {
3050 match app.select_yolo_compat() {
3051 SettingSelection::Changed => (
3052 format!("Switched to {} mode.", AppMode::Agent.display_name()),
3053 true,
3054 ),
3055 SettingSelection::PersistedSame => {
3056 (app.mode_startup_default_receipt(AppMode::Agent), false)
3057 }
3058 SettingSelection::Refused => (
3059 app.setting_locked_message(MessageId::SettingSubjectMode),
3060 false,
3061 ),
3062 }
3063 }
3064
3065 /// `/theme [name]` — with no argument, open the interactive picker (arrow
3066 /// keys, live preview, Enter to persist, Esc to revert). With an argument,
3067 /// route through `set_config_value("theme", ...)` so the apply + save flow is
3068 /// shared with `/config`.
3069 pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
3070 match arg.map(str::trim).filter(|s| !s.is_empty()) {
3071 None => CommandResult::action(AppAction::OpenThemePicker),
3072 Some("schema") => CommandResult::message(codewhale_palette::user_theme_schema_json()),
3073 Some("path") => match codewhale_palette::user_themes_dir() {
3074 Ok(path) => CommandResult::message(format!(
3075 "User themes: {}\nSelect with: /theme custom:<name>",
3076 path.display()
3077 )),
3078 Err(error) => CommandResult::error(error),
3079 },
3080 // `underwater` is an ordinary theme (aliases `deepsea`/`deep-sea`/
3081 // `ombre` fold through the same normalizer); the painted ocean field
3082 // is the theme itself, not a treatment beside it.
3083 Some(name) => set_config_value(app, "theme", name, true),
3084 }
3085 }
3086
3087 /// Manage workspace-level trust and the per-path allowlist.
3088 ///
3089 /// Subcommands:
3090 /// - `/trust` – show current state and trusted external paths
3091 /// - `/trust on|off` – change file-tool trust for this session only
3092 /// - `/trust on|off --save` – also persist workspace trust for project sources
3093 /// - `/trust add <path>` – add a directory to the allowlist (#29)
3094 /// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
3095 /// - `/trust list` – list trusted external paths for this workspace
3096 pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
3097 let raw = arg.map(str::trim).unwrap_or("");
3098 let mut parts = raw.splitn(2, char::is_whitespace);
3099 let sub = parts.next().unwrap_or("").to_lowercase();
3100 let rest = parts.next().map(str::trim).unwrap_or("");
3101 let workspace = app.workspace.clone();
3102
3103 match sub.as_str() {
3104 "" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
3105 "on" | "enable" | "yes" | "y" | "off" | "disable" | "no" | "n" => {
3106 if !matches!(rest, "" | "--save") {
3107 return CommandResult::error(format!(
3108 "{} /trust on|off [--save]",
3109 tr(app.ui_locale, MessageId::HelpUsageLabel)
3110 ));
3111 }
3112 CommandResult::action(AppAction::SetWorkspaceTrust {
3113 trusted: matches!(sub.as_str(), "on" | "enable" | "yes" | "y"),
3114 save: rest == "--save",
3115 })
3116 }
3117 "add" => trust_add(&workspace, rest),
3118 "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
3119 other => CommandResult::error(format!(
3120 "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off [--save]`, `/trust add <path>`, or `/trust remove <path>`."
3121 )),
3122 }
3123 }
3124
3125 pub(crate) async fn set_workspace_trust(app: &mut App, trusted: bool, save: bool) -> Result<()> {
3126 if !save {
3127 app.trust_mode = trusted;
3128 return Ok(());
3129 }
3130 // Revocation restricts live file access even if the saved decision cannot be updated.
3131 if !trusted {
3132 app.trust_mode = false;
3133 }
3134 let workspace = app.workspace.clone();
3135 #[cfg(test)]
3136 let ticket = crate::test_support::env_scope_ticket();
3137 tokio::task::spawn_blocking(move || {
3138 #[cfg(test)]
3139 let _membership = crate::test_support::join_env_scope(ticket);
3140 crate::config::set_workspace_trust(&workspace, trusted)
3141 })
3142 .await??;
3143 app.trust_mode = trusted;
3144 Ok(())
3145 }
3146
3147 fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
3148 let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
3149 let mut lines = Vec::new();
3150 lines.push(format!(
3151 "Workspace trust mode: {}",
3152 if app.trust_mode {
3153 "enabled"
3154 } else {
3155 "disabled"
3156 }
3157 ));
3158 if trust.paths().is_empty() {
3159 if force_paths {
3160 lines.push("No external paths trusted from this workspace.".to_string());
3161 } else {
3162 lines.push(
3163 "No external paths trusted yet. Use `/trust add <path>` to allow a directory."
3164 .to_string(),
3165 );
3166 }
3167 } else {
3168 lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
3169 for path in trust.paths() {
3170 lines.push(format!(" • {}", path.display()));
3171 }
3172 }
3173 CommandResult::message(lines.join("\n"))
3174 }
3175
3176 fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
3177 if raw.is_empty() {
3178 return CommandResult::error(
3179 "Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
3180 );
3181 }
3182 let path = PathBuf::from(expand_tilde(raw));
3183 if !path.exists() {
3184 return CommandResult::error(format!(
3185 "Path not found: {} — supply an existing directory or file.",
3186 path.display()
3187 ));
3188 }
3189 match crate::workspace_trust::add(workspace, &path) {
3190 Ok(stored) => CommandResult::message(format!(
3191 "Added to trust list for this workspace: {}",
3192 stored.display()
3193 )),
3194 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
3195 }
3196 }
3197
3198 fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
3199 if raw.is_empty() {
3200 return CommandResult::error("Usage: /trust remove <path>");
3201 }
3202 let path = PathBuf::from(expand_tilde(raw));
3203 match crate::workspace_trust::remove(workspace, &path) {
3204 Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
3205 Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
3206 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
3207 }
3208 }
3209
3210 fn expand_tilde(raw: &str) -> String {
3211 if let Some(rest) = raw.strip_prefix("~/")
3212 && let Some(home) = crate::config::effective_home_dir()
3213 {
3214 return home.join(rest).to_string_lossy().into_owned();
3215 } else if raw == "~"
3216 && let Some(home) = crate::config::effective_home_dir()
3217 {
3218 return home.to_string_lossy().into_owned();
3219 }
3220 raw.to_string()
3221 }
3222
3223 /// Toggle LSP diagnostics on/off or show status.
3224 ///
3225 /// - `/lsp on` — enable inline LSP diagnostics
3226 /// - `/lsp off` — disable inline LSP diagnostics
3227 /// - `/lsp status` — show whether diagnostics are currently enabled
3228 pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
3229 let raw = arg.map(str::trim).unwrap_or("");
3230 // Access lsp_manager config through the App's engine handle
3231 let current_enabled = app.lsp_enabled;
3232
3233 match raw {
3234 "" | "status" => {
3235 let status = if current_enabled { "on" } else { "off" };
3236 CommandResult::message(format!(
3237 "LSP diagnostics are currently **{status}**.\n\n\
3238 Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits."
3239 ))
3240 }
3241 "on" | "enable" | "1" | "true" => {
3242 app.lsp_enabled = true;
3243 CommandResult::message(
3244 "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.",
3245 )
3246 }
3247 "off" | "disable" | "0" | "false" => {
3248 app.lsp_enabled = false;
3249 CommandResult::message("LSP diagnostics disabled.")
3250 }
3251 other => CommandResult::error(format!(
3252 "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`."
3253 )),
3254 }
3255 }
3256
3257 /// Unified login status. Account device flow stays on the CLI so this
3258 /// command never freezes the TUI and never invents a second OAuth broker.
3259 /// The internal cloud-agent credential is not user surface: membership
3260 /// (`codewhale login`) is the only door, never a provider key.
3261 pub fn login(app: &mut App, arg: Option<&str>) -> CommandResult {
3262 let raw = arg.map(str::trim).unwrap_or("");
3263 let token = raw.split_whitespace().next().unwrap_or("");
3264 match token {
3265 "" | "status" => CommandResult::message(login_status_text(app)),
3266 "key" | "provider" => CommandResult::with_message_and_action(
3267 "Open the provider picker to store an API key. Account sign-in is `codewhale login`.",
3268 AppAction::OpenProviderPicker,
3269 ),
3270 "account" => CommandResult::message(
3271 "TUI cannot start the browser device flow without freezing the session.\n\
3272 Run `codewhale login` (same as `codewhale account login`) in a terminal.\n\
3273 Then `/login` to confirm the session landed."
3274 .to_string(),
3275 ),
3276 other => CommandResult::error(format!(
3277 "Usage: /login [status|account|key]\nUnknown argument: {other}"
3278 )),
3279 }
3280 }
3281
3282 fn login_status_text(app: &App) -> String {
3283 use codewhale_secrets::account::{
3284 ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3285 secure_account_session_secrets,
3286 };
3287
3288 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3289 .ok()
3290 .map(|value| value.trim().trim_end_matches('/').to_string())
3291 .filter(|value| !value.is_empty())
3292 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3293 let account = match secure_account_session_secrets() {
3294 Ok(secrets) => {
3295 match AccountSessionStore::new(secrets, None, &api_base)
3296 .runtime_info_at(chrono::Utc::now())
3297 {
3298 Ok(info) => match info.state {
3299 AccountSessionState::SignedOut => format!("not signed in (api {api_base})"),
3300 AccountSessionState::Authenticated => format!("signed in (api {api_base})"),
3301 AccountSessionState::OfflineCached => {
3302 format!("offline cached (api {api_base})")
3303 }
3304 AccountSessionState::Expired => format!("expired (api {api_base})"),
3305 AccountSessionState::Revoked => format!("revoked (api {api_base})"),
3306 },
3307 Err(error) => format!("unavailable ({error})"),
3308 }
3309 }
3310 Err(error) => format!("unavailable ({error})"),
3311 };
3312 let provider = app.provider_identity_for_persistence();
3313 format!(
3314 "Codewhale login\n\
3315 Account: {account}\n\
3316 Active provider: {provider}\n\
3317 \n\
3318 Sign in: `codewhale login`\n\
3319 Provider key: `codewhale auth set --provider <id>` or `/login key`\n\
3320 Sign out: `/logout` or `codewhale logout`"
3321 )
3322 }
3323
3324 fn clear_local_account_session() -> Result<bool, String> {
3325 use codewhale_secrets::account::{
3326 ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
3327 secure_account_session_secrets,
3328 };
3329 let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
3330 let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
3331 .ok()
3332 .map(|value| value.trim().trim_end_matches('/').to_string())
3333 .filter(|value| !value.is_empty())
3334 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
3335 let store = AccountSessionStore::new(secrets, None, &api_base);
3336 let had = store.load().map_err(|error| error.to_string())?.is_some();
3337 store.clear().map_err(|error| error.to_string())?;
3338 Ok(had)
3339 }
3340
3341 fn clear_daytona_slot() -> Result<bool, String> {
3342 let secrets = codewhale_secrets::Secrets::auto_detect();
3343 let had = secrets
3344 .get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
3345 .map_err(|error| error.to_string())?
3346 .is_some_and(|value| !value.trim().is_empty());
3347 if had {
3348 secrets
3349 .delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)
3350 .map_err(|error| error.to_string())?;
3351 }
3352 Ok(had)
3353 }
3354
3355 /// Logout — clear the active provider key, the Codewhale account session,
3356 /// and the Daytona slot. Named custom providers still clear only their own
3357 /// table. For a full every-provider wipe, use `codewhale logout`.
3358 pub fn logout(app: &mut App) -> CommandResult {
3359 let provider_name = app.provider_identity_for_persistence().to_string();
3360 match clear_active_provider_api_key(&provider_name) {
3361 Ok(()) => {
3362 app.onboarding = OnboardingState::Provider;
3363 app.onboarding_needs_api_key = true;
3364 app.onboarding_provider = app.api_provider;
3365 app.onboarding_missing_key_recovery = true;
3366 app.api_key_env_only = false;
3367 let mut cleared = vec![format!("provider key ({provider_name})")];
3368 match clear_local_account_session() {
3369 Ok(true) => cleared.push("Codewhale account session".to_string()),
3370 Ok(false) => {}
3371 Err(error) => cleared.push(format!("account session not cleared ({error})")),
3372 }
3373 match clear_daytona_slot() {
3374 Ok(true) => cleared.push("internal cloud-agent token".to_string()),
3375 Ok(false) => {}
3376 Err(error) => {
3377 cleared.push(format!("internal cloud-agent token not cleared ({error})"))
3378 }
3379 }
3380 CommandResult::with_message_and_action(
3381 format!(
3382 "Cleared {}. \
3383 Use `codewhale login` to sign in again, or `codewhale auth set --provider <id>` to store a provider key.",
3384 cleared.join(", ")
3385 ),
3386 AppAction::OpenProviderPicker,
3387 )
3388 }
3389 Err(e) => CommandResult::error(format!("Failed to clear API key for {provider_name}: {e}")),
3390 }
3391 }
3392
3393 #[cfg(test)]
3394 mod tests {
3395 use super::*;
3396 use crate::config::Config;
3397 use crate::config::NotificationMethod;
3398 use crate::test_support::{EnvVarGuard, TestEnvLock, lock_test_env};
3399 use crate::tui::app::{App, TuiOptions};
3400 use std::env;
3401 use std::fs;
3402 use std::path::Path;
3403 use std::path::PathBuf;
3404 use std::time::{SystemTime, UNIX_EPOCH};
3405
3406 struct EnvGuard {
3407 _vars: Vec<EnvVarGuard>,
3408 _lock: TestEnvLock,
3409 }
3410
3411 impl EnvGuard {
3412 fn new(home: &Path) -> Self {
3413 let lock = lock_test_env();
3414 let config_path = home.join(".deepseek").join("config.toml");
3415 let vars = vec![
3416 EnvVarGuard::set("HOME", home),
3417 EnvVarGuard::set("USERPROFILE", home),
3418 EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")),
3419 EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
3420 EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", config_path),
3421 EnvVarGuard::remove("CODEWHALE_ALLOW_SHELL"),
3422 EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL"),
3423 EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY"),
3424 EnvVarGuard::remove("NO_ANIMATIONS"),
3425 EnvVarGuard::remove("TERM_PROGRAM"),
3426 EnvVarGuard::remove("PTYXIS_VERSION"),
3427 EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER"),
3428 EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER"),
3429 EnvVarGuard::remove("TAVILY_API_KEY"),
3430 ];
3431 Self {
3432 _vars: vars,
3433 _lock: lock,
3434 }
3435 }
3436 }
3437
3438 fn create_test_app_with_config(config: &Config) -> App {
3439 let options = TuiOptions {
3440 model: "test-model".to_string(),
3441 // Keep command tests independent from the developer's saved
3442 // `default_mode` setting: with `false`, App::new starts in the
3443 // saved mode, so a machine with `default_mode = "yolo"` flips
3444 // `allow_shell` on and breaks the allow_shell assertions.
3445 start_in_agent_mode: true,
3446 skip_onboarding: false,
3447 ..crate::test_support::test_tui_options(PathBuf::from("."))
3448 };
3449 let mut app = App::new(options, config);
3450 // App::new folds in saved TUI settings from the developer machine.
3451 // Pin command tests back to DeepSeek semantics so model aliases are
3452 // not normalized through a provider selected in an interactive run.
3453 app.model = "test-model".to_string();
3454 app.auto_model = false;
3455 app.api_provider = crate::config::ProviderKind::Deepseek;
3456 app.model_ids_passthrough = false;
3457 app
3458 }
3459
3460 fn create_test_app() -> App {
3461 create_test_app_with_config(&Config::default())
3462 }
3463
3464 #[test]
3465 fn contextual_tips_disable_only_guidance_and_keep_session_cap() {
3466 use crate::tui::app::{StatusToast, StatusToastKind, StatusToastLevel};
3467 use crate::tui::behavioral_tips::BehavioralTip;
3468
3469 let temp = tempfile::tempdir().unwrap();
3470 let _guard = EnvGuard::new(temp.path());
3471 let mut app = create_test_app();
3472 app.status_toasts.clear();
3473 assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation));
3474 app.push_status_toast("warning receipt", StatusToastLevel::Warning, None);
3475 app.push_status_toast("error receipt", StatusToastLevel::Error, None);
3476 app.sticky_status = Some(StatusToast::context_pressure(
3477 "context warning",
3478 crate::context_budget::PressureLevel::High,
3479 ));
3480
3481 let result = crate::commands::execute("/config contextual_tips off", &mut app);
3482 assert!(!result.is_error);
3483 assert!(!app.behavioral_tips.enabled());
3484 assert_eq!(
3485 app.status_toasts
3486 .iter()
3487 .map(|toast| toast.text.as_str())
3488 .collect::<Vec<_>>(),
3489 ["warning receipt", "error receipt"]
3490 );
3491 assert!(matches!(
3492 app.sticky_status.as_ref().unwrap().kind,
3493 StatusToastKind::ContextPressure(_)
3494 ));
3495 assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation));
3496
3497 assert!(!crate::commands::execute("/config contextual_tips on", &mut app).is_error);
3498 assert!(app.behavioral_tips.enabled());
3499 assert!(
3500 !app.maybe_show_behavioral_tip(BehavioralTip::McpValidation),
3501 "reenabling must not reset the session cap"
3502 );
3503 }
3504
3505 #[test]
3506 fn contextual_tips_command_persists_and_reports_failed_save() {
3507 let temp = tempfile::tempdir().unwrap();
3508 let _guard = EnvGuard::new(temp.path());
3509 let mut app = create_test_app();
3510 Settings::transact(|settings| {
3511 settings.theme = "terminal".into();
3512 settings
3513 .behavioral_tip_impressions
3514 .insert("planning_mode".into(), 2);
3515 Ok(())
3516 })
3517 .unwrap();
3518
3519 let result = crate::commands::execute("/config contextual_tips off --save", &mut app);
3520 assert!(!result.is_error);
3521 let saved = Settings::load_persisted().unwrap();
3522 assert!(!saved.contextual_tips);
3523 assert_eq!(saved.theme, "terminal");
3524 assert_eq!(
3525 saved.behavioral_tip_impressions.get("planning_mode"),
3526 Some(&2)
3527 );
3528 assert!(
3529 !create_test_app().behavioral_tips.enabled(),
3530 "restart must load the saved opt-out"
3531 );
3532
3533 assert!(!crate::commands::execute("/config contextual_tips on --save", &mut app).is_error);
3534 assert!(create_test_app().behavioral_tips.enabled());
3535 assert_eq!(
3536 Settings::load_persisted()
3537 .unwrap()
3538 .behavioral_tip_impressions
3539 .get("planning_mode"),
3540 Some(&2)
3541 );
3542 let path = Settings::path().unwrap();
3543 let before = fs::read(&path).unwrap();
3544 assert!(
3545 crate::commands::execute("/config contextual_tips invalid --save", &mut app).is_error
3546 );
3547 assert_eq!(fs::read(&path).unwrap(), before);
3548 assert!(app.behavioral_tips.enabled());
3549
3550 let malformed = "contextual_tips = [private_fixture_payload\n";
3551 fs::write(&path, malformed).unwrap();
3552 let failed = crate::commands::execute("/config contextual_tips off --save", &mut app);
3553 assert!(failed.is_error);
3554 assert!(
3555 !app.behavioral_tips.enabled(),
3556 "failed persistence still honors the session opt-out"
3557 );
3558 assert_eq!(fs::read_to_string(path).unwrap(), malformed);
3559 let message = failed.message.unwrap();
3560 assert!(message.contains("could not be saved"), "{message}");
3561 assert!(
3562 !message.contains("private_fixture_payload"),
3563 "parse errors must not echo settings contents"
3564 );
3565 assert!(message.ends_with(&app.status_toasts.back().unwrap().text));
3566 }
3567
3568 #[test]
3569 fn screen_commands_dispatch_to_the_matching_screen_mode() {
3570 let mut app = create_test_app();
3571 assert_eq!(app.screen_mode, ScreenMode::Fullscreen);
3572
3573 let switched = crate::commands::execute("/inline", &mut app);
3574 assert_eq!(
3575 switched.action,
3576 Some(AppAction::SetScreenMode(ScreenMode::Inline)),
3577 "/inline must ask for the inline screen"
3578 );
3579 assert!(!switched.is_error, "/inline must not be an error");
3580
3581 // The action is applied where the terminal lives, so the app is still
3582 // fullscreen here; asking for the current mode reports, it does not
3583 // emit a second action.
3584 let repeated = crate::commands::execute("/fullscreen", &mut app);
3585 assert!(
3586 repeated.action.is_none(),
3587 "already-current mode must not re-switch"
3588 );
3589 assert!(
3590 repeated
3591 .message
3592 .as_deref()
3593 .is_some_and(|msg| msg.contains("Already on the fullscreen screen")),
3594 "{:?}",
3595 repeated.message
3596 );
3597
3598 let rejected = crate::commands::execute("/inline sideways", &mut app);
3599 assert!(rejected.is_error, "/inline takes no argument");
3600 }
3601
3602 #[test]
3603 fn config_unknown_setting_suggests_nearest_key() {
3604 let mut app = create_test_app();
3605 let result = config_command(&mut app, Some("auto_compcat"));
3606 assert!(result.is_error);
3607 let text = result.message.as_deref().unwrap_or_default();
3608 assert!(
3609 text.contains("Did you mean `/config auto_compact`?"),
3610 "{text}"
3611 );
3612 assert!(text.contains("/settings text"), "{text}");
3613
3614 let result = config_command(&mut app, Some("zzqqxxyy"));
3615 assert!(result.is_error);
3616 let text = result.message.as_deref().unwrap_or_default();
3617 assert!(!text.contains("Did you mean"), "{text}");
3618 assert!(text.contains("/settings text"), "{text}");
3619
3620 // Internal flags, actions and retired schema defs are not settings a
3621 // user can look up, and are never suggested.
3622 for internal in ["feature_intro_shown", "mcp_open", "fast_model"] {
3623 let result = config_command(&mut app, Some(internal));
3624 assert!(result.is_error, "{internal}: {:?}", result.message);
3625 let text = result.message.as_deref().unwrap_or_default();
3626 assert!(!text.contains(&format!("`/config {internal}`")), "{text}");
3627 }
3628 }
3629
3630 #[test]
3631 fn config_workflow_and_goal_explain_the_effective_tables() {
3632 let mut app = create_test_app();
3633 for token in ["workflow", "goal"] {
3634 let result = config_command(&mut app, Some(token));
3635 assert!(
3636 result.action.is_none(),
3637 "{token} must not spend a model turn"
3638 );
3639 let text = result.message.as_deref().unwrap_or_default();
3640 assert!(
3641 text.contains("require_approval_for_writes"),
3642 "{token}: {text}"
3643 );
3644 assert!(text.contains("max_continuations"), "{token}: {text}");
3645 }
3646 }
3647
3648 #[test]
3649 fn title_config_reports_the_default_not_the_session_override() {
3650 let config = Config {
3651 title: Some(" workspace\u{1b}]0;ignored\u{7}\u{202e}-default ".to_string()),
3652 ..Config::default()
3653 };
3654 let mut app = create_test_app_with_config(&config);
3655 assert_eq!(
3656 app.title_default.as_deref(),
3657 Some("workspace]0;ignored-default")
3658 );
3659 app.window_title = Some("session-override".to_string());
3660
3661 let shown = show_single_setting(&app, "title");
3662
3663 assert_eq!(
3664 shown.message.as_deref(),
3665 Some("title = workspace]0;ignored-default")
3666 );
3667 }
3668
3669 #[test]
3670 fn title_config_normalizes_the_live_and_persisted_default() {
3671 let dir = tempfile::tempdir().expect("isolated config dir");
3672 let config_path = dir.path().join("config.toml");
3673 let mut app = create_test_app();
3674 app.config_path = Some(config_path.clone());
3675
3676 let result = set_config_value(
3677 &mut app,
3678 "title",
3679 " Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta ",
3680 true,
3681 );
3682
3683 assert!(!result.is_error, "{:?}", result.message);
3684 assert_eq!(app.title_default.as_deref(), Some("Ev]0;PWNEDil Beta"));
3685 assert!(app.needs_redraw);
3686 let loaded = Config::load(Some(config_path), None).expect("reload saved config");
3687 assert_eq!(loaded.title.as_deref(), Some("Ev]0;PWNEDil Beta"));
3688 }
3689
3690 /// The shipped preset must survive its own preflight, or `/config preset
3691 /// calm` would be refused for a reason the user cannot act on.
3692 #[test]
3693 fn the_shipped_preset_passes_its_own_preflight() {
3694 let app = create_test_app();
3695 let fields = crate::settings::preset_fields("calm").expect("the calm preset exists");
3696 assert_eq!(preset_preflight(&app, fields), None);
3697 }
3698
3699 /// A field the setter would reject must be caught *before* the transaction
3700 /// opens. Previously the bundle was saved first and the per-field mirror
3701 /// pass then failed, leaving the user with an error message and a rewritten
3702 /// settings file.
3703 #[test]
3704 fn preset_preflight_refuses_an_invalid_field_before_any_write() {
3705 let app = create_test_app();
3706 let refusal = preset_preflight(&app, &[("calm_mode", "true"), ("low_motion", "banana")])
3707 .expect("an invalid value must be refused");
3708 assert!(
3709 refusal.contains("low_motion"),
3710 "the refusal must name the offending field, got {refusal:?}"
3711 );
3712 }
3713
3714 /// A preset carrying a live-route key is refused whole while a turn runs,
3715 /// rather than saving the bundle and then failing on that one field.
3716 #[test]
3717 fn preset_preflight_refuses_a_live_route_field_while_a_turn_runs() {
3718 let mut app = create_test_app();
3719 app.is_loading = true;
3720 let bundle = [("calm_mode", "true"), ("reasoning_effort", "high")];
3721 let refusal =
3722 preset_preflight(&app, &bundle).expect("a live-route field must be refused mid-turn");
3723 assert!(
3724 refusal.contains("locked while a turn is running"),
3725 "got {refusal:?}"
3726 );
3727
3728 app.is_loading = false;
3729 assert_eq!(
3730 preset_preflight(&app, &bundle),
3731 None,
3732 "the same bundle must apply once the turn ends"
3733 );
3734 }
3735
3736 /// The refusal list is the contract for #2982 on the slash surfaces. Keep
3737 /// restart-only `default_mode` out of it: `set_config_value` deliberately
3738 /// does not apply that key to the live session.
3739 #[test]
3740 fn live_route_key_list_covers_every_route_mutating_alias() {
3741 for key in [
3742 "mode",
3743 "model",
3744 "default_model",
3745 "reasoning_effort",
3746 "effort",
3747 "provider",
3748 "approval_mode",
3749 "approval_policy",
3750 "approval",
3751 ] {
3752 assert!(
3753 live_route_setting_subject(key).is_some(),
3754 "{key} mutates the active route and must be locked mid-turn"
3755 );
3756 }
3757 for key in ["default_mode", "theme", "calm_mode", "rail_panel"] {
3758 assert!(
3759 live_route_setting_subject(key).is_none(),
3760 "{key} does not mutate the active route and must stay settable"
3761 );
3762 }
3763 }
3764
3765 #[test]
3766 fn approval_aliases_are_inert_while_a_turn_is_running() {
3767 let mut app = create_test_app();
3768 app.approval_mode = ApprovalMode::Suggest;
3769 app.is_loading = true;
3770
3771 for key in ["approval_mode", "approval_policy", "approval"] {
3772 let result = set_config_value(&mut app, key, "never", false);
3773 assert!(result.is_error, "{key} must be refused mid-turn");
3774 assert!(
3775 result
3776 .message
3777 .as_deref()
3778 .is_some_and(|message| message.contains("locked while a turn is running")),
3779 "unexpected refusal for {key}: {:?}",
3780 result.message
3781 );
3782 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
3783 }
3784 }
3785
3786 #[test]
3787 fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() {
3788 let mut app = create_test_app();
3789 app.calm_mode = false;
3790 app.show_thinking = true;
3791 app.show_tool_details = true;
3792 app.fancy_animations = true;
3793
3794 let result = config_command(&mut app, Some("preset calm"));
3795 let message = result.message.unwrap_or_default();
3796 assert!(
3797 message.contains("calm"),
3798 "summary should name the preset: {message}"
3799 );
3800
3801 assert!(app.calm_mode);
3802 assert!(!app.show_tool_details);
3803 assert!(app.low_motion);
3804 assert!(!app.fancy_animations);
3805 assert_eq!(
3806 app.tool_collapse_mode,
3807 crate::tui::app::ToolCollapseMode::Calm
3808 );
3809 assert_eq!(
3810 app.transcript_spacing,
3811 crate::tui::app::TranscriptSpacing::Compact
3812 );
3813 // Evidence preserved: thinking is not hidden by the preset.
3814 assert!(app.show_thinking, "calm preset must not hide thinking");
3815 }
3816
3817 #[test]
3818 fn config_preset_unknown_name_reports_error() {
3819 let mut app = create_test_app();
3820 let result = config_command(&mut app, Some("preset turbo"));
3821 let message = result.message.unwrap_or_default();
3822 assert!(
3823 message.to_lowercase().contains("unknown preset"),
3824 "expected unknown-preset error, got: {message}"
3825 );
3826 }
3827
3828 #[test]
3829 fn config_preset_save_without_name_reports_usage() {
3830 let mut app = create_test_app();
3831 let result = config_command(&mut app, Some("preset --save"));
3832 let message = result.message.unwrap_or_default();
3833 assert!(
3834 message.contains("Usage: /config preset"),
3835 "expected usage hint, got: {message}"
3836 );
3837 assert!(!result.is_error);
3838 }
3839
3840 #[test]
3841 fn work_surface_config_applies_live_and_accepts_bottom() {
3842 let mut app = create_test_app();
3843
3844 let result = set_config_value(&mut app, "work_surface_placement", "left", false);
3845 assert!(!result.is_error, "{:?}", result.message);
3846 assert_eq!(
3847 app.work_surface.placement,
3848 crate::tui::work_surface::WorkSurfacePlacement::Left
3849 );
3850 let shown = show_single_setting(&app, "work_surface_placement");
3851 assert_eq!(
3852 shown.message.as_deref(),
3853 Some("work_surface_placement = left")
3854 );
3855
3856 let result = set_config_value(&mut app, "work_surface_placement", "bottom", false);
3857 assert!(!result.is_error, "{:?}", result.message);
3858 assert_eq!(
3859 app.work_surface.placement,
3860 crate::tui::work_surface::WorkSurfacePlacement::Bottom
3861 );
3862 }
3863
3864 #[test]
3865 fn rail_command_on_restores_default_bottom_placement() {
3866 let mut app = create_test_app();
3867 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
3868
3869 let result = sidebar(&mut app, Some("on"));
3870
3871 assert!(!result.is_error);
3872 assert_eq!(
3873 app.work_surface.placement,
3874 crate::tui::work_surface::WorkSurfacePlacement::Bottom
3875 );
3876 let message = result.message.unwrap_or_default();
3877 assert!(message.contains("bottom placement"), "got: {message}");
3878 }
3879
3880 #[test]
3881 fn pet_sound_command_is_opt_in_and_rejects_invalid_changes() {
3882 let mut app = create_test_app();
3883 let status = pet(&mut app, Some("sound"));
3884 assert!(!status.is_error);
3885 assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff);
3886
3887 assert!(!pet(&mut app, Some(" SOUND ON ")).is_error);
3888 assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused);
3889 for invalid in ["sound yes", "sound off extra", "sound on --save"] {
3890 assert!(pet(&mut app, Some(invalid)).is_error, "{invalid}");
3891 assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused);
3892 }
3893 assert!(!pet(&mut app, Some("sound off")).is_error);
3894 assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff);
3895 }
3896
3897 #[test]
3898 fn pet_command_toggles_the_habitat_and_automatic_entry() {
3899 let mut app = create_test_app();
3900 app.onboarding = crate::tui::app::OnboardingState::None;
3901 app.redaction_gate = false;
3902 app.input = "kept draft".into();
3903 app.pet_watch.detach_for_test();
3904
3905 let on = pet(&mut app, None);
3906 assert!(!on.is_error);
3907 assert!(app.pet_watch.enabled);
3908 assert!(crate::tui::pet_watch::is_open(&app));
3909 assert_eq!(
3910 on.message.as_deref(),
3911 Some(&*tr(app.ui_locale, MessageId::PetModeOn))
3912 );
3913 // Repeating `on` is harmless: still one habitat, still enabled.
3914 assert!(!pet(&mut app, Some(" ON ")).is_error);
3915 assert!(app.pet_watch.enabled);
3916 assert!(crate::tui::pet_watch::is_open(&app));
3917
3918 let off = pet(&mut app, Some("off"));
3919 assert!(!off.is_error);
3920 assert!(!app.pet_watch.enabled);
3921 assert!(!crate::tui::pet_watch::is_open(&app));
3922 assert!(app.view_stack.is_empty());
3923 assert_eq!(app.input, "kept draft");
3924 assert_eq!(
3925 off.message.as_deref(),
3926 Some(&*tr(app.ui_locale, MessageId::PetModeOff))
3927 );
3928
3929 // Bare /pet toggles back on; unknown verbs are refused with usage.
3930 app.pet_watch.detach_for_test();
3931 assert!(!pet(&mut app, None).is_error);
3932 assert!(app.pet_watch.enabled);
3933 assert!(pet(&mut app, Some("bogus")).is_error);
3934 let status = pet(&mut app, Some("status"));
3935 assert!(!status.is_error);
3936 let message = status.message.unwrap_or_default();
3937 assert!(
3938 message.contains(&*tr(app.ui_locale, MessageId::PetModeOnLabel)),
3939 "{message}"
3940 );
3941 assert!(
3942 message.contains(&*tr(app.ui_locale, MessageId::PetViewOpen)),
3943 "{message}"
3944 );
3945 }
3946
3947 #[test]
3948 fn rail_command_reports_narrow_terminal_top_fallback() {
3949 let mut app = create_test_app();
3950 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left;
3951 // A 60-column host is below the side-rail floor, so the effective
3952 // placement falls back to top; the status must say so rather than
3953 // claim a left workbar renders.
3954 let _ = crate::tui::work_surface::height(&mut app, 60, 24, u16::MAX);
3955
3956 let result = sidebar(&mut app, None);
3957
3958 assert!(!result.is_error);
3959 let message = result.message.unwrap_or_default();
3960 assert!(message.contains("left placement"), "got: {message}");
3961 assert!(message.contains("showing top for now"), "got: {message}");
3962 }
3963
3964 #[test]
3965 fn rail_command_off_never_claims_visibility() {
3966 let mut app = create_test_app();
3967
3968 let result = sidebar(&mut app, Some("off"));
3969
3970 assert!(!result.is_error);
3971 assert_eq!(
3972 app.work_surface.placement,
3973 crate::tui::work_surface::WorkSurfacePlacement::Off
3974 );
3975 let message = result.message.unwrap_or_default();
3976 assert!(message.contains("Workbar is off"), "got: {message}");
3977 assert!(
3978 !message.contains("Workbar is visible"),
3979 "the readout must never claim a hidden surface renders: {message}"
3980 );
3981 }
3982
3983 #[test]
3984 fn rail_command_rejects_retired_auto_mode() {
3985 let mut app = create_test_app();
3986
3987 let result = sidebar(&mut app, Some("auto"));
3988
3989 assert!(result.is_error);
3990 assert!(
3991 result
3992 .message
3993 .as_deref()
3994 .unwrap_or_default()
3995 .contains("Usage: /workbar")
3996 );
3997 }
3998
3999 #[test]
4000 fn test_mode_yolo_sets_all_flags() {
4001 let mut app = create_test_app();
4002 // Switch to Agent first to guarantee a clean starting state regardless of
4003 // user settings on the host machine.
4004 let _ = mode(&mut app, Some("agent"));
4005 let result = mode(&mut app, Some("yolo"));
4006 // YOLO is invisible Act+Bypass shorthand — user-facing copy says Act.
4007 assert!(result.message.unwrap().contains("Switched to Act mode"));
4008 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
4009 assert!(app.allow_shell);
4010 assert!(app.trust_mode);
4011 assert!(app.yolo);
4012 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
4013 // The deprecated YOLO alias remaps to Agent mode (M6 compat shim).
4014 assert_eq!(app.mode, AppMode::Agent);
4015 }
4016
4017 #[test]
4018 fn test_mode_switch_command_accepts_names_and_numbers() {
4019 let mut app = create_test_app();
4020 let _ = mode(&mut app, Some("agent"));
4021 assert_eq!(app.mode, AppMode::Agent);
4022 let result = mode(&mut app, Some("2"));
4023 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan)));
4024 assert_eq!(app.mode, AppMode::Plan);
4025 let result = mode(&mut app, Some("act"));
4026 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
4027 assert_eq!(app.mode, AppMode::Agent);
4028 let _ = mode(&mut app, Some("plan"));
4029 assert_eq!(app.mode, AppMode::Plan);
4030 let result = mode(&mut app, Some("3"));
4031 assert_eq!(
4032 result.action,
4033 Some(AppAction::ModeChanged(AppMode::Operate))
4034 );
4035 assert_eq!(app.mode, AppMode::Operate);
4036 let result = mode(&mut app, Some("5"));
4037 assert!(result.is_error);
4038 assert_eq!(app.mode, AppMode::Operate);
4039 let result = mode(&mut app, Some("9"));
4040 assert!(result.is_error);
4041 assert_eq!(app.mode, AppMode::Operate);
4042 let result = mode(&mut app, Some("4"));
4043 // "4" still routes to the deprecated YOLO alias, which lands in Agent
4044 // mode with bypass approvals (M6 compat shim).
4045 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
4046 assert_eq!(app.mode, AppMode::Agent);
4047 assert!(app.yolo);
4048 }
4049
4050 #[test]
4051 fn test_mode_without_arg_opens_picker() {
4052 let mut app = create_test_app();
4053 let result = mode(&mut app, None);
4054 assert!(result.message.is_none());
4055 assert!(matches!(result.action, Some(AppAction::OpenModePicker)));
4056 }
4057
4058 #[test]
4059 fn test_mode_rejects_unknown_value() {
4060 let mut app = create_test_app();
4061 let result = mode(&mut app, Some("fast"));
4062 assert!(result.is_error);
4063 assert!(result.message.unwrap().contains("Usage: /mode"));
4064 }
4065
4066 #[test]
4067 fn test_show_config_defaults_to_native() {
4068 let mut app = create_test_app();
4069 app.session.total_tokens = 1234;
4070 let result = show_config(&mut app, None);
4071 assert!(result.message.is_none());
4072 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
4073 }
4074
4075 #[test]
4076 fn test_show_config_native_opens_config_view() {
4077 let mut app = create_test_app();
4078 let result = show_config(&mut app, Some("native"));
4079 assert!(result.message.is_none());
4080 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
4081 }
4082
4083 #[test]
4084 fn test_show_config_tui_and_web_open_the_same_config_view() {
4085 let mut app = create_test_app();
4086 for arg in ["tui", "web", "TUI", " Web "] {
4087 let result = show_config(&mut app, Some(arg));
4088 assert!(result.message.is_none(), "{arg}");
4089 assert!(
4090 matches!(result.action, Some(AppAction::OpenConfigView)),
4091 "{arg}"
4092 );
4093 }
4094 }
4095
4096 #[test]
4097 fn test_show_config_rejects_unknown_editor() {
4098 let mut app = create_test_app();
4099 let result = show_config(&mut app, Some("vim"));
4100 assert!(result.is_error);
4101 assert!(result.message.unwrap().contains("Usage: /config"));
4102 }
4103
4104 #[test]
4105 fn test_show_settings_loads_from_file() {
4106 let _lock = lock_test_env();
4107 let mut app = create_test_app();
4108 let result = show_settings(&mut app);
4109 // Settings should load (may use defaults if file doesn't exist)
4110 assert!(result.message.is_some());
4111 }
4112
4113 #[test]
4114 fn settings_command_opens_typed_editor_and_preserves_text_mode() {
4115 let _lock = lock_test_env();
4116 let mut app = create_test_app();
4117
4118 let modal = settings_command(&mut app, None);
4119 assert!(modal.message.is_none());
4120 assert!(matches!(modal.action, Some(AppAction::OpenConfigView)));
4121
4122 let text = settings_command(&mut app, Some("text"));
4123 let message = text.message.as_deref().expect("settings diagnostic text");
4124 assert!(message.contains("Settings:"), "{message}");
4125 assert!(
4126 message.contains("model defaults: config.toml"),
4127 "{message}"
4128 );
4129 assert!(!message.contains("provider_models:"), "{message}");
4130 assert!(message.contains("Config file:"), "{message}");
4131 assert!(text.action.is_none());
4132 }
4133
4134 #[test]
4135 fn config_model_updates_app_state() {
4136 let mut app = create_test_app();
4137 let _old_model = app.model.clone();
4138 let result = config_command(&mut app, Some("model deepseek-v4-flash"));
4139 assert!(result.message.is_some());
4140 let msg = result.message.unwrap();
4141 assert!(msg.contains("model = deepseek-v4-flash"));
4142 assert_eq!(app.model, "deepseek-v4-flash");
4143 assert!(matches!(
4144 result.action,
4145 Some(AppAction::UpdateCompaction(_))
4146 ));
4147 }
4148
4149 #[test]
4150 fn config_model_rejects_foreign_model_for_direct_provider() {
4151 let mut app = create_test_app();
4152 app.api_provider = ProviderKind::Zai;
4153 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
4154
4155 let result = set_config_value(&mut app, "model", "deepseek-v4-pro", false);
4156
4157 assert!(result.is_error);
4158 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
4159 assert!(result.action.is_none());
4160 let message = result.message.as_deref().expect("rejection message");
4161 assert!(
4162 message.contains("not compatible with provider 'zai'")
4163 || message.contains("not served by direct provider zai"),
4164 "unexpected rejection message: {message}"
4165 );
4166 assert!(message.contains("deepseek-v4-pro"), "{message}");
4167 }
4168
4169 #[test]
4170 fn config_model_auto_preserves_explicit_thinking() {
4171 let mut app = create_test_app();
4172 app.reasoning_effort = ReasoningEffort::Off;
4173 app.reasoning_effort_preference = Some(ReasoningEffort::Off);
4174
4175 let result = config_command(&mut app, Some("model auto"));
4176
4177 assert!(result.message.is_some());
4178 assert!(app.auto_model);
4179 assert_eq!(app.model, "auto");
4180 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
4181 assert!(
4182 result
4183 .message
4184 .as_deref()
4185 .is_some_and(|message| message.contains("thinking = off"))
4186 );
4187 assert!(app.last_effective_model.is_none());
4188 assert!(app.last_effective_reasoning_effort.is_none());
4189 }
4190
4191 #[test]
4192 fn config_model_auto_releases_implicit_fixed_model_thinking() {
4193 let mut app = create_test_app();
4194 app.reasoning_effort = ReasoningEffort::Max;
4195 app.reasoning_effort_preference = None;
4196
4197 let result = config_command(&mut app, Some("model auto"));
4198
4199 assert!(result.message.is_some());
4200 assert!(app.auto_model);
4201 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
4202 assert_eq!(app.reasoning_effort_preference, None);
4203 assert!(
4204 result
4205 .message
4206 .as_deref()
4207 .is_some_and(|message| message.contains("thinking = auto"))
4208 );
4209 }
4210
4211 #[test]
4212 fn config_reasoning_effort_applies_while_model_routing_is_auto() {
4213 let mut app = create_test_app();
4214 app.set_model_selection("auto".to_string());
4215 app.reasoning_effort = ReasoningEffort::Auto;
4216 app.reasoning_effort_preference = None;
4217
4218 let result = set_config_value(&mut app, "reasoning_effort", "low", false);
4219
4220 assert!(!result.is_error);
4221 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
4222 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low));
4223 assert!(matches!(
4224 result.action,
4225 Some(AppAction::UpdateCompaction(_))
4226 ));
4227 }
4228
4229 #[test]
4230 fn config_default_model_cannot_replace_a_non_deepseek_live_route() {
4231 let temp_root = tempfile::tempdir().expect("isolated configuration");
4232 let _guard = EnvGuard::new(temp_root.path());
4233 let config_path = crate::config_persistence::config_toml_path(None).expect("config path");
4234 fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir");
4235 fs::write(
4236 &config_path,
4237 "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n",
4238 )
4239 .expect("config");
4240 let mut app = create_test_app();
4241 app.api_provider = ProviderKind::Zai;
4242 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
4243 app.auto_model = false;
4244
4245 let session_only = set_config_value(&mut app, "default_model", "deepseek-v4-flash", false);
4246
4247 assert!(session_only.is_error);
4248 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
4249 assert!(session_only.action.is_none());
4250 assert!(
4251 session_only
4252 .message
4253 .as_deref()
4254 .is_some_and(|message| message.contains("DeepSeek startup fallback"))
4255 );
4256
4257 let saved = set_config_value(&mut app, "default_model", "deepseek-v4-flash", true);
4258
4259 assert!(!saved.is_error);
4260 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
4261 assert!(saved.action.is_none());
4262 assert!(
4263 saved
4264 .message
4265 .as_deref()
4266 .is_some_and(|message| message.contains("active zai/GLM-5.2 is unchanged"))
4267 );
4268 let persisted: toml::Value =
4269 toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml");
4270 assert_eq!(
4271 persisted["providers"]["deepseek"]["model"].as_str(),
4272 Some("deepseek-v4-flash")
4273 );
4274 assert_eq!(persisted["provider"].as_str(), Some("zai"));
4275 assert_eq!(
4276 saved_deepseek_default_model(&app).expect("saved value"),
4277 "deepseek-v4-flash"
4278 );
4279 assert!(
4280 Settings::load_persisted()
4281 .expect("settings")
4282 .default_model
4283 .is_none()
4284 );
4285 }
4286
4287 #[test]
4288 fn saved_automatic_model_selection_round_trips_every_provider_route() {
4289 let temp_root = tempfile::tempdir().expect("isolated configuration");
4290 let _guard = EnvGuard::new(temp_root.path());
4291 let config_path = crate::config_persistence::config_toml_path(None).unwrap();
4292 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
4293 for (provider, selector, table, initial) in [
4294 (
4295 ProviderKind::Deepseek,
4296 "deepseek",
4297 "deepseek",
4298 "deepseek-v4-pro",
4299 ),
4300 (
4301 ProviderKind::Deepseek,
4302 "deepseek-cn",
4303 "deepseek_cn",
4304 "deepseek-v4-pro",
4305 ),
4306 (ProviderKind::Zai, "zai", "zai", "GLM-5.3"),
4307 ] {
4308 fs::write(
4309 &config_path,
4310 format!("provider = '{selector}'\n[providers.{table}]\nmodel = '{initial}'\n"),
4311 )
4312 .unwrap();
4313 let mut app = create_test_app();
4314 app.set_provider_identity(provider, selector);
4315 app.model = initial.to_string();
4316 app.auto_model = false;
4317 let result = set_config_value(&mut app, "model", "auto", true);
4318 assert!(!result.is_error, "{:?}", result.message);
4319 assert!(app.auto_model);
4320 assert!(
4321 result
4322 .message
4323 .as_deref()
4324 .is_some_and(|m| m.contains("saved"))
4325 );
4326 let persisted: toml::Value =
4327 toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
4328 assert_eq!(
4329 persisted["providers"][table]["model"].as_str(),
4330 Some("auto")
4331 );
4332 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
4333 assert_eq!(
4334 loaded.default_model(),
4335 "auto",
4336 "{selector} must consume its saved choice"
4337 );
4338 }
4339 }
4340
4341 #[test]
4342 fn config_default_model_save_accepts_models_declared_for_the_deepseek_route() {
4343 let temp_root = tempfile::tempdir().expect("isolated configuration");
4344 let _guard = EnvGuard::new(temp_root.path());
4345 // The declaration matches on the saved DeepSeek route's base URL; keep
4346 // ambient endpoint overrides out of the comparison.
4347 let _base_url_env = [
4348 EnvVarGuard::remove("CODEWHALE_BASE_URL"),
4349 EnvVarGuard::remove("DEEPSEEK_BASE_URL"),
4350 ];
4351 let config_path = crate::config_persistence::config_toml_path(None).expect("config path");
4352 fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir");
4353 fs::write(
4354 &config_path,
4355 "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://my-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n",
4356 )
4357 .expect("config");
4358
4359 let mut app = create_test_app();
4360 app.api_provider = ProviderKind::Zai;
4361 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
4362 app.auto_model = false;
4363
4364 let result = set_config_value(&mut app, "default_model", "my-llm", true);
4365
4366 assert!(!result.is_error, "{:?}", result.message);
4367 let persisted: toml::Value =
4368 toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml");
4369 assert_eq!(
4370 persisted["providers"]["deepseek"]["model"].as_str(),
4371 Some("my-llm")
4372 );
4373 // The same id on a different DeepSeek endpoint is not declared for the
4374 // saved route and must still be rejected by catalog normalization.
4375 fs::write(
4376 &config_path,
4377 "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://other-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n",
4378 )
4379 .expect("config");
4380 let rejected = set_config_value(&mut app, "default_model", "my-llm", true);
4381 assert!(rejected.is_error, "{:?}", rejected.message);
4382 }
4383
4384 #[test]
4385 fn saved_model_display_uses_the_same_profile_root_precedence_as_startup() {
4386 let temp_root = tempfile::tempdir().unwrap();
4387 let _guard = EnvGuard::new(temp_root.path());
4388 let path = crate::config_persistence::config_toml_path(None).unwrap();
4389 fs::create_dir_all(path.parent().unwrap()).unwrap();
4390 let mut app = create_test_app();
4391 app.config_path = Some(path.clone());
4392 app.config_profile = Some("pro".to_string());
4393 for field in ["default_text_model", "model"] {
4394 fs::write(&path, format!("route_preferences_version = 1\nprovider = 'deepseek'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[profiles.pro]\n{field} = 'deepseek-v4-pro'\n")).unwrap();
4395 let configured =
4396 Config::from_saved_document(&fs::read_to_string(&path).unwrap(), Some("pro"))
4397 .unwrap();
4398 assert_eq!(configured.default_model(), "deepseek-v4-pro");
4399 assert_eq!(
4400 saved_deepseek_default_model(&app).unwrap(),
4401 configured.default_model()
4402 );
4403 }
4404 }
4405
4406 #[test]
4407 fn config_reasoning_effort_uses_codex_provider_labels() {
4408 let temp_root = env::temp_dir().join(format!(
4409 "codewhale-tui-codex-effort-config-test-{}",
4410 std::process::id()
4411 ));
4412 fs::create_dir_all(&temp_root).unwrap();
4413 let _guard = EnvGuard::new(&temp_root);
4414 let mut app = create_test_app();
4415 app.api_provider = ProviderKind::OpenaiCodex;
4416 app.reasoning_effort = ReasoningEffort::High;
4417
4418 let result = set_config_value(&mut app, "reasoning_effort", "off", false);
4419
4420 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
4421 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
4422 assert_eq!(
4423 result.message.as_deref(),
4424 Some("reasoning_effort = low (session only, add --save to persist)")
4425 );
4426
4427 let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false);
4428
4429 // `xhigh` stopped collapsing into `Max` when the ladder gave it a rung.
4430 assert_eq!(app.reasoning_effort, ReasoningEffort::XHigh);
4431 assert_eq!(
4432 result.message.as_deref(),
4433 Some("reasoning_effort = xhigh (session only, add --save to persist)")
4434 );
4435 }
4436
4437 #[test]
4438 fn config_fancy_animations_keeps_ghostty_full_motion() {
4439 let temp_root = env::temp_dir().join(format!(
4440 "codewhale-tui-ghostty-fancy-config-test-{}",
4441 std::process::id()
4442 ));
4443 fs::create_dir_all(&temp_root).unwrap();
4444 let _guard = EnvGuard::new(&temp_root);
4445 // Neutralize the SSH markers: production intentionally caps motion
4446 // over SSH, and the suite routinely runs inside one.
4447 let _ssh_client = EnvVarGuard::remove("SSH_CLIENT");
4448 let _ssh_connection = EnvVarGuard::remove("SSH_CONNECTION");
4449 let _ssh_tty = EnvVarGuard::remove("SSH_TTY");
4450 let prev_term_program = env::var_os("TERM_PROGRAM");
4451 // Safety: test-only environment mutation guarded by EnvGuard's lock.
4452 unsafe {
4453 env::set_var("TERM_PROGRAM", "Ghostty");
4454 }
4455
4456 let mut app = create_test_app();
4457 assert!(app.fancy_animations);
4458 assert!(!app.constrained_frame_rate);
4459
4460 let result = set_config_value(&mut app, "fancy_animations", "true", false);
4461
4462 assert!(!result.is_error);
4463 assert!(
4464 app.fancy_animations,
4465 "Ghostty must keep authored motion enabled"
4466 );
4467 assert_eq!(
4468 result.message.as_deref(),
4469 Some("fancy_animations = true (session only, add --save to persist)")
4470 );
4471
4472 // Safety: cleanup under EnvGuard's lock.
4473 unsafe {
4474 match prev_term_program {
4475 Some(v) => env::set_var("TERM_PROGRAM", v),
4476 None => env::remove_var("TERM_PROGRAM"),
4477 }
4478 }
4479 }
4480
4481 #[test]
4482 fn config_model_accepts_future_deepseek_model_id() {
4483 let mut app = create_test_app();
4484 let result = config_command(&mut app, Some("model deepseek-v4"));
4485 assert!(result.message.is_some());
4486 let msg = result.message.unwrap();
4487 assert!(msg.contains("model = deepseek-v4"));
4488 assert_eq!(app.model, "deepseek-v4");
4489 }
4490
4491 #[test]
4492 fn config_model_with_save_flag() {
4493 let temp_root = tempfile::tempdir().expect("isolated settings dir");
4494 let _guard = EnvGuard::new(temp_root.path());
4495 let mut app = create_test_app();
4496 let result = config_command(&mut app, Some("model deepseek-v4-flash --save"));
4497 assert!(!result.is_error, "{:?}", result.message);
4498 assert_eq!(app.model, "deepseek-v4-flash");
4499 let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
4500 .expect("config path");
4501 let persisted: toml::Value =
4502 toml::from_str(&fs::read_to_string(config_path).expect("saved config")).expect("toml");
4503 assert_eq!(persisted["provider"].as_str(), Some("deepseek"));
4504 assert_eq!(
4505 persisted["providers"]["deepseek"]["model"].as_str(),
4506 Some("deepseek-v4-flash")
4507 );
4508 assert!(
4509 Settings::load_persisted()
4510 .expect("settings")
4511 .provider_models
4512 .is_none()
4513 );
4514 }
4515
4516 #[test]
4517 fn failed_model_save_keeps_the_live_selection() {
4518 let temp_root = tempfile::tempdir().expect("isolated config");
4519 let _guard = EnvGuard::new(temp_root.path());
4520 let mut app = create_test_app();
4521 let previous = app.model.clone();
4522 let blocked_path = temp_root.path().join("config-directory");
4523 fs::create_dir(&blocked_path).expect("blocked config path");
4524 app.config_path = Some(blocked_path);
4525
4526 let result = config_command(&mut app, Some("model deepseek-v4-flash --save"));
4527
4528 assert!(result.is_error);
4529 assert!(result.action.is_none());
4530 assert_eq!(app.model, previous);
4531 }
4532
4533 #[test]
4534 fn hosted_ollama_model_saves_keep_the_legacy_provider_slot() {
4535 use crate::tui::app::PendingRouteSave;
4536 use crate::tui::views::route_save_prompt::RouteSaveChoice;
4537
4538 let temp_root = tempfile::tempdir().expect("isolated config");
4539 let _guard = EnvGuard::new(temp_root.path());
4540 let config_path = temp_root.path().join(".codewhale/config.toml");
4541 fs::create_dir_all(config_path.parent().expect("config parent")).expect("config home");
4542 fs::write(
4543 &config_path,
4544 "provider = \"ollama\"\n[providers.ollama]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"original:cloud\"\napi_key_env = \"TEST_OLLAMA_KEY\"\n",
4545 )
4546 .expect("legacy hosted config");
4547 let mut app = create_test_app();
4548 app.config_path = Some(config_path.clone());
4549 app.set_provider_identity_record(
4550 Config::load(Some(config_path.clone()), None)
4551 .expect("captured hosted config")
4552 .active_provider_identity()
4553 .expect("captured hosted identity"),
4554 );
4555 app.active_route_base_url = "https://ollama.com/v1".to_string();
4556 app.model_ids_passthrough = true;
4557
4558 for (index, model) in ["live:cloud", "pending:cloud", "command:cloud"]
4559 .into_iter()
4560 .enumerate()
4561 {
4562 app.model = model.to_string();
4563 match index {
4564 0 => {
4565 let receipt = app
4566 .try_save_live_route_as_startup_default()
4567 .expect("remember live hosted route");
4568 assert!(receipt.contains("ollama-cloud/live:cloud"), "{receipt}");
4569 }
4570 1 => {
4571 app.pending_route_save = Some(PendingRouteSave {
4572 provider_identity: "ollama-cloud".to_string(),
4573 model: model.to_string(),
4574 fleet: None,
4575 });
4576 let receipt = app.apply_route_save_choice(RouteSaveChoice::SaveAsDefault);
4577 assert!(receipt.starts_with("Remembered "), "{receipt}");
4578 }
4579 _ => {
4580 let result = set_config_value(&mut app, "model", model, true);
4581 assert!(!result.is_error, "{:?}", result.message);
4582 }
4583 }
4584 let saved: toml::Value =
4585 toml::from_str(&fs::read_to_string(&config_path).expect("saved config"))
4586 .expect("valid config");
4587 assert_eq!(saved["provider"].as_str(), Some("ollama"));
4588 assert_eq!(saved["providers"]["ollama"]["model"].as_str(), Some(model));
4589 assert_eq!(
4590 saved["providers"]["ollama"]["base_url"].as_str(),
4591 Some("https://ollama.com/v1")
4592 );
4593 assert_eq!(
4594 saved["providers"]["ollama"]["api_key_env"].as_str(),
4595 Some("TEST_OLLAMA_KEY")
4596 );
4597 assert!(saved["providers"].get("ollama_cloud").is_none());
4598 assert!(saved["providers"].get("ollama-cloud").is_none());
4599 assert_eq!(app.provider_identity_for_persistence(), "ollama-cloud");
4600 assert_eq!(app.provider_id_for_persistence(), Some("ollama"));
4601 }
4602 }
4603
4604 #[test]
4605 fn config_default_mode_normal_save_reports_normalized_value() {
4606 let nanos = SystemTime::now()
4607 .duration_since(UNIX_EPOCH)
4608 .unwrap()
4609 .as_nanos();
4610 let temp_root = env::temp_dir().join(format!(
4611 "codewhale-tui-default-mode-test-{}-{}",
4612 std::process::id(),
4613 nanos
4614 ));
4615 fs::create_dir_all(&temp_root).unwrap();
4616 let _guard = EnvGuard::new(&temp_root);
4617
4618 let mut app = create_test_app();
4619 let result = config_command(&mut app, Some("default_mode normal --save"));
4620 let msg = result.message.unwrap();
4621 assert_eq!(msg, "default_mode = agent (saved)");
4622 assert_eq!(app.mode, AppMode::Agent);
4623
4624 let settings_path = Settings::path().unwrap();
4625 let saved = fs::read_to_string(settings_path).unwrap();
4626 assert!(saved.contains("default_mode = \"agent\""));
4627 }
4628
4629 #[test]
4630 fn config_command_cost_currency_save_persists_value() {
4631 let nanos = SystemTime::now()
4632 .duration_since(UNIX_EPOCH)
4633 .unwrap()
4634 .as_nanos();
4635 let temp_root = env::temp_dir().join(format!(
4636 "codewhale-tui-cost-currency-test-{}-{}",
4637 std::process::id(),
4638 nanos
4639 ));
4640 fs::create_dir_all(&temp_root).unwrap();
4641 let _guard = EnvGuard::new(&temp_root);
4642
4643 let mut app = create_test_app();
4644 let result = config_command(&mut app, Some("cost_currency cny --save"));
4645 let msg = result.message.unwrap();
4646
4647 assert_eq!(msg, "cost_currency = cny (saved)");
4648 assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
4649
4650 let settings_path = Settings::path().unwrap();
4651 let saved = fs::read_to_string(settings_path).unwrap();
4652 assert!(saved.contains("cost_currency = \"cny\""));
4653 }
4654
4655 #[test]
4656 fn config_command_base_url_save_persists_value() {
4657 let nanos = SystemTime::now()
4658 .duration_since(UNIX_EPOCH)
4659 .unwrap()
4660 .as_nanos();
4661 let temp_root = env::temp_dir().join(format!(
4662 "deepseek-tui-base-url-test-{}-{}",
4663 std::process::id(),
4664 nanos
4665 ));
4666 fs::create_dir_all(&temp_root).unwrap();
4667 let _guard = EnvGuard::new(&temp_root);
4668
4669 let mut app = create_test_app();
4670 let result = config_command(
4671 &mut app,
4672 Some("base_url https://example.internal.local/v1 --save"),
4673 );
4674 let msg = result.message.unwrap();
4675 let saved_path = crate::config_persistence::config_toml_path(None).unwrap();
4676 let saved = fs::read_to_string(&saved_path).unwrap();
4677
4678 // The active DeepSeek route's own table, not a top-level key (#6394).
4679 assert_eq!(
4680 msg,
4681 format!(
4682 "base_url = https://example.internal.local/v1 for deepseek (saved to {}; restart required)",
4683 saved_path.display()
4684 )
4685 );
4686 let table: toml::Table = toml::from_str(&saved).unwrap();
4687 assert_eq!(
4688 table["providers"]["deepseek"]["base_url"].as_str(),
4689 Some("https://example.internal.local/v1")
4690 );
4691 assert!(table.get("base_url").is_none(), "{saved}");
4692 }
4693
4694 #[test]
4695 fn config_command_provider_emits_switch_action() {
4696 let mut app = create_test_app();
4697 let result = config_command(&mut app, Some("provider openrouter"));
4698
4699 assert!(!result.is_error);
4700 assert_eq!(result.message.as_deref(), Some("provider = openrouter"));
4701 match result.action {
4702 Some(AppAction::SwitchProvider { provider, model }) => {
4703 assert_eq!(provider.as_str(), ProviderKind::Openrouter.as_str());
4704 assert_eq!(model, None);
4705 }
4706 other => panic!("expected SwitchProvider action, got {other:?}"),
4707 }
4708 }
4709
4710 #[test]
4711 fn config_command_provider_rejects_unknown_provider() {
4712 let mut app = create_test_app();
4713 // "anthropic" became a real provider in #3014; probe with an id that
4714 // stays unknown.
4715 let result = config_command(&mut app, Some("provider not-a-provider"));
4716 assert!(result.is_error);
4717 let msg = result.message.unwrap();
4718 assert!(msg.contains("Unknown provider 'not-a-provider'"));
4719 assert!(msg.contains("openrouter"));
4720 assert!(msg.contains("xiaomi-mimo"));
4721 }
4722
4723 #[test]
4724 fn config_command_allow_shell_enables_agent_shell_session_only() {
4725 let mut app = create_test_app();
4726 assert!(!app.allow_shell);
4727
4728 let result = config_command(&mut app, Some("allow_shell true"));
4729 assert!(!result.is_error);
4730 assert!(app.allow_shell);
4731 let msg = result.message.unwrap();
4732
4733 assert!(msg.contains("allow_shell = true"));
4734 assert!(msg.contains("session only"));
4735 assert!(msg.contains("Act mode"));
4736 assert!(msg.contains("approval gating"));
4737 assert!(msg.contains("next turn"));
4738 assert!(msg.contains("Full Access (Shift+Tab) also enables shell and auto-approves"));
4739 }
4740
4741 #[test]
4742 fn config_command_allow_shell_save_persists_root_boolean() {
4743 let temp_root = tempfile::tempdir().expect("isolated config dir");
4744 let _guard = EnvGuard::new(temp_root.path());
4745
4746 let config_path = temp_root.path().join("custom-config.toml");
4747
4748 let mut app = create_test_app();
4749 app.config_path = Some(config_path.clone());
4750 let result = config_command(&mut app, Some("allow_shell true --save"));
4751 let msg = result.message.unwrap();
4752 let saved = fs::read_to_string(&config_path).unwrap();
4753
4754 assert!(app.allow_shell);
4755 assert_eq!(
4756 msg,
4757 format!(
4758 "allow_shell = true (saved to {}). Act mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves.",
4759 config_path.display()
4760 )
4761 );
4762 assert!(saved.contains("allow_shell = true"));
4763 }
4764
4765 #[test]
4766 fn config_command_allow_shell_rejects_invalid_boolean() {
4767 let mut app = create_test_app();
4768 let result = config_command(&mut app, Some("allow_shell maybe"));
4769 assert!(result.is_error);
4770 assert!(!app.allow_shell);
4771 let msg = result.message.unwrap();
4772 assert!(msg.contains("Failed to parse boolean 'maybe'"));
4773 }
4774
4775 #[test]
4776 fn config_command_cannot_bypass_project_shell_constraint() {
4777 let temp_root = env::temp_dir().join(format!(
4778 "codewhale-project-shell-control-test-{}",
4779 std::process::id()
4780 ));
4781 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
4782 let _guard = EnvGuard::new(&temp_root);
4783 let root_config = temp_root.join(".deepseek").join("config.toml");
4784 fs::write(&root_config, "# user root\n").unwrap();
4785 let workspace = temp_root.join("workspace");
4786 fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
4787 fs::write(
4788 workspace
4789 .join(codewhale_config::CODEWHALE_APP_DIR)
4790 .join("config.toml"),
4791 "allow_shell = false\n",
4792 )
4793 .unwrap();
4794 let mut app = create_test_app();
4795 app.config_path = Some(root_config.clone());
4796 app.workspace = workspace;
4797 app.set_agent_shell_access(false);
4798
4799 let result = config_command(&mut app, Some("allow_shell true --save"));
4800
4801 assert!(result.is_error, "{:?}", result.message);
4802 assert!(!app.allow_shell);
4803 assert!(
4804 result
4805 .message
4806 .as_deref()
4807 .is_some_and(|message| message.contains("project configuration"))
4808 );
4809 assert!(
4810 !fs::read_to_string(root_config)
4811 .unwrap()
4812 .contains("allow_shell")
4813 );
4814 }
4815
4816 #[test]
4817 fn config_command_cannot_bypass_environment_shell_constraint() {
4818 let temp_root = env::temp_dir().join(format!(
4819 "codewhale-env-shell-control-test-{}",
4820 std::process::id()
4821 ));
4822 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
4823 let _guard = EnvGuard::new(&temp_root);
4824 let config_path = temp_root.join(".deepseek").join("config.toml");
4825 fs::write(&config_path, "# root\n").unwrap();
4826 // Safety: EnvGuard holds the process-wide environment lock and restores
4827 // this variable on drop.
4828 unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") };
4829 let config = Config::load(Some(config_path.clone()), None).unwrap();
4830 let mut app = create_test_app_with_config(&config);
4831 app.config_path = Some(config_path);
4832 app.set_agent_shell_access(false);
4833
4834 let result = config_command(&mut app, Some("allow_shell true"));
4835
4836 assert!(result.is_error, "{:?}", result.message);
4837 assert!(!app.allow_shell);
4838 assert!(
4839 result
4840 .message
4841 .as_deref()
4842 .is_some_and(|message| message.contains("DEEPSEEK_ALLOW_SHELL"))
4843 );
4844 }
4845
4846 #[test]
4847 fn config_command_cannot_bypass_project_or_environment_approval() {
4848 let temp_root = env::temp_dir().join(format!(
4849 "codewhale-external-approval-control-test-{}",
4850 std::process::id()
4851 ));
4852 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
4853 let _guard = EnvGuard::new(&temp_root);
4854 let root_config = temp_root.join(".deepseek").join("config.toml");
4855 fs::write(&root_config, "# root\n").unwrap();
4856 let workspace = temp_root.join("workspace");
4857 fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
4858 fs::write(
4859 workspace
4860 .join(codewhale_config::CODEWHALE_APP_DIR)
4861 .join("config.toml"),
4862 "approval_policy = \"never\"\n",
4863 )
4864 .unwrap();
4865 let mut app = create_test_app();
4866 app.config_path = Some(root_config.clone());
4867 app.workspace = workspace;
4868 app.set_agent_approval_posture(ApprovalMode::Never);
4869
4870 let project_result = config_command(&mut app, Some("approval_mode full-access"));
4871 assert!(project_result.is_error, "{:?}", project_result.message);
4872 assert_eq!(app.approval_mode, ApprovalMode::Never);
4873
4874 // Move outside the project and make the environment the controlling
4875 // source for the second half of the regression.
4876 app.workspace = temp_root.join("clean-workspace");
4877 fs::create_dir_all(&app.workspace).unwrap();
4878 // Safety: EnvGuard holds the process-wide environment lock and restores
4879 // this variable on drop.
4880 unsafe { env::set_var("DEEPSEEK_APPROVAL_POLICY", "never") };
4881 let env_result = config_command(&mut app, Some("approval_mode auto"));
4882 assert!(env_result.is_error, "{:?}", env_result.message);
4883 assert_eq!(app.approval_mode, ApprovalMode::Never);
4884 assert!(
4885 env_result
4886 .message
4887 .as_deref()
4888 .is_some_and(|message| message.contains("DEEPSEEK_APPROVAL_POLICY"))
4889 );
4890 }
4891
4892 #[test]
4893 fn config_command_shell_choice_survives_plan_round_trip() {
4894 let mut app = create_test_app();
4895 app.set_agent_approval_posture(ApprovalMode::Bypass);
4896
4897 let result = config_command(&mut app, Some("allow_shell true"));
4898
4899 assert!(!result.is_error, "{:?}", result.message);
4900 app.set_mode(AppMode::Plan);
4901 assert!(!app.allow_shell);
4902 app.set_mode(AppMode::Agent);
4903 assert!(app.allow_shell);
4904 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
4905 }
4906
4907 #[test]
4908 fn config_command_subagents_off_save_persists_and_updates_runtime() {
4909 let temp_root = env::temp_dir().join(format!(
4910 "codewhale-subagents-off-save-test-{}",
4911 std::process::id()
4912 ));
4913 fs::create_dir_all(&temp_root).unwrap();
4914 let config_path = temp_root.join("custom-config.toml");
4915
4916 let mut app = create_test_app();
4917 app.config_path = Some(config_path.clone());
4918 let result = config_command(&mut app, Some("subagents off --save"));
4919 let msg = result.message.unwrap();
4920 let saved = fs::read_to_string(&config_path).unwrap();
4921
4922 assert!(!result.is_error);
4923 assert!(msg.contains("subagents.enabled = false"));
4924 assert!(msg.contains("saved to"));
4925 assert!(saved.contains("[subagents]"));
4926 assert!(saved.contains("enabled = false"));
4927 match result.action {
4928 Some(AppAction::UpdateSubagentRuntimeConfig { enabled, .. }) => {
4929 assert!(!enabled);
4930 }
4931 other => panic!("expected subagent runtime update, got {other:?}"),
4932 }
4933 }
4934
4935 #[test]
4936 fn config_command_subagents_depth_save_clamps_to_ceiling() {
4937 let temp_root = env::temp_dir().join(format!(
4938 "codewhale-subagents-depth-save-test-{}",
4939 std::process::id()
4940 ));
4941 fs::create_dir_all(&temp_root).unwrap();
4942 let config_path = temp_root.join("custom-config.toml");
4943
4944 let mut app = create_test_app();
4945 app.config_path = Some(config_path.clone());
4946 let result = config_command(&mut app, Some("subagents max_depth 99 --save"));
4947 let msg = result.message.unwrap();
4948 let saved = fs::read_to_string(&config_path).unwrap();
4949 let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING;
4950
4951 assert!(!result.is_error);
4952 assert!(msg.contains(&format!("subagents.max_depth = {ceiling}")));
4953 assert!(msg.contains(&format!("clamped from 99 to {ceiling}")));
4954 assert!(saved.contains(&format!("max_depth = {ceiling}")));
4955 match result.action {
4956 Some(AppAction::UpdateSubagentRuntimeConfig {
4957 max_spawn_depth, ..
4958 }) => {
4959 assert_eq!(max_spawn_depth, ceiling);
4960 }
4961 other => panic!("expected subagent runtime update, got {other:?}"),
4962 }
4963 }
4964
4965 #[test]
4966 fn config_command_subagents_status_shows_raw_and_resolved_values() {
4967 let temp_root = env::temp_dir().join(format!(
4968 "codewhale-subagents-status-test-{}",
4969 std::process::id()
4970 ));
4971 fs::create_dir_all(&temp_root).unwrap();
4972 let config_path = temp_root.join("custom-config.toml");
4973 fs::write(
4974 &config_path,
4975 r#"
4976 [subagents]
4977 enabled = true
4978 max_concurrent = 2
4979 max_depth = 0
4980 launch_concurrency = 5
4981 api_timeout_secs = 0
4982 heartbeat_timeout_secs = 1
4983 "#,
4984 )
4985 .unwrap();
4986
4987 let mut app = create_test_app();
4988 app.config_path = Some(config_path);
4989 let result = config_command(&mut app, Some("subagents status"));
4990 let msg = result.message.unwrap();
4991
4992 assert!(!result.is_error);
4993 assert!(msg.contains("Sub-agents: disabled (subagents.max_depth=0)"));
4994 assert!(msg.contains("Active provider: deepseek"));
4995 assert!(
4996 msg.contains("subagents.max_concurrent = 2 (resolved global 2; active provider 2)")
4997 );
4998 assert!(
4999 msg.contains("subagents.launch_concurrency = 5 (resolved global 2; active provider 2)")
5000 );
5001 assert!(
5002 msg.contains(
5003 "subagents.api_timeout_secs = 0 (resolved global 600; active provider 600)"
5004 )
5005 );
5006 assert!(msg.contains(
5007 "subagents.heartbeat_timeout_secs = 1 (resolved global 630; active provider 630)"
5008 ));
5009 assert!(msg.contains("subagents.providers.deepseek = inherits global"));
5010 }
5011
5012 #[test]
5013 fn config_command_audit_lists_editability_and_current_values() {
5014 let temp_root = env::temp_dir().join(format!(
5015 "codewhale-config-audit-test-{}",
5016 std::process::id()
5017 ));
5018 fs::create_dir_all(&temp_root).unwrap();
5019 // Hermetic: the audit reads Settings::load(); without this guard the
5020 // developer's real saved permission_posture leaks in and the
5021 // "(unset)" assertion below becomes machine-dependent.
5022 let _guard = EnvGuard::new(&temp_root);
5023 let config_path = temp_root.join("custom-config.toml");
5024 fs::write(
5025 &config_path,
5026 r#"
5027 base_url = "https://api.from-config.local/v1"
5028 instructions = ["~/global.md"]
5029 prompt_suggestion = true
5030
5031 [subagents]
5032 enabled = false
5033 max_concurrent = 4
5034
5035 [search]
5036 provider = "bing"
5037
5038 [notifications]
5039 method = "osc9"
5040 threshold_secs = 45
5041 quiet = true
5042 completion_sound = "off"
5043 "#,
5044 )
5045 .unwrap();
5046
5047 let mut app = create_test_app();
5048 app.config_path = Some(config_path.clone());
5049 app.approval_mode = ApprovalMode::Never;
5050 app.stream_chunk_timeout_secs = 45;
5051
5052 let result = config_command(&mut app, Some("audit"));
5053 let msg = result.message.unwrap();
5054
5055 assert!(!result.is_error);
5056 assert!(msg.contains("Config editability audit"));
5057 assert!(msg.contains(&format!("Config path: {}", config_path.display())));
5058 assert!(msg.contains("effective_permissions | Never | runtime"));
5059 assert!(msg.contains("permission_posture | (unset) | TUI settings"));
5060 assert!(msg.contains("approval_policy | (unset) | persisted config"));
5061 assert!(msg.contains("stream_chunk_timeout_secs | 45 | runtime+persisted"));
5062 assert!(msg.contains("subagents.enabled | false | runtime+persisted"));
5063 assert!(msg.contains("subagents.max_concurrent | 4 | runtime+persisted"));
5064 assert!(msg.contains("base_url | https://api.from-config.local/v1 | persisted restart"));
5065 assert!(msg.contains("providers.<active>.context_window | (unset) | persisted restart"));
5066 assert!(msg.contains("effective_context_window |"), "{msg}");
5067 assert!(msg.contains("| runtime | /config context_window"), "{msg}");
5068 assert!(msg.contains("instructions | configured | file-only restart"));
5069 assert!(msg.contains("network | unset | file-only"));
5070 assert!(
5071 msg.contains("search.provider | bing (source: config.toml) | runtime+persisted"),
5072 "{msg}"
5073 );
5074 assert!(
5075 msg.contains("prompt_suggestion | true | runtime+persisted"),
5076 "{msg}"
5077 );
5078 assert!(
5079 msg.contains(
5080 "notifications | method=osc9 threshold=45s sound=legacy quiet=true | runtime+persisted"
5081 ),
5082 "{msg}"
5083 );
5084
5085 app.mode = AppMode::Plan;
5086 let plan_msg = config_command(&mut app, Some("audit"))
5087 .message
5088 .expect("Plan audit message");
5089 assert!(
5090 plan_msg.contains("effective_permissions | Read Only | runtime"),
5091 "{plan_msg}"
5092 );
5093 }
5094
5095 #[test]
5096 fn config_command_shows_search_prompt_suggestion_and_notifications() {
5097 let temp_root = env::temp_dir().join(format!(
5098 "codewhale-config-discovery-show-{}",
5099 std::process::id()
5100 ));
5101 fs::create_dir_all(&temp_root).unwrap();
5102 let _guard = EnvGuard::new(&temp_root);
5103 let config_path = temp_root.join("custom-config.toml");
5104 fs::write(
5105 &config_path,
5106 r#"
5107 prompt_suggestion = true
5108
5109 [search]
5110 provider = "tavily"
5111
5112 [notifications]
5113 method = "bel"
5114 threshold_secs = 12
5115 quiet = false
5116 completion_sound = "bell"
5117 "#,
5118 )
5119 .unwrap();
5120
5121 let mut app = create_test_app();
5122 app.config_path = Some(config_path.clone());
5123 // This fixture starts App with defaults; seed its current Config view
5124 // as the real constructor does before asking a live-session query.
5125 app.notification_settings = Config::load(Some(config_path), None)
5126 .unwrap()
5127 .notifications_config();
5128
5129 let search = config_command(&mut app, Some("search.provider"));
5130 assert!(!search.is_error, "{:?}", search.message);
5131 assert_eq!(
5132 search.message.as_deref(),
5133 Some("search.provider = tavily (source: config.toml)")
5134 );
5135
5136 let suggestion = config_command(&mut app, Some("prompt_suggestion"));
5137 assert!(!suggestion.is_error, "{:?}", suggestion.message);
5138 assert_eq!(
5139 suggestion.message.as_deref(),
5140 Some("prompt_suggestion = true")
5141 );
5142
5143 let notifications = config_command(&mut app, Some("notifications"));
5144 let notifications_msg = notifications.message.expect("notifications status");
5145 assert!(!notifications.is_error, "{notifications_msg}");
5146 assert!(
5147 notifications_msg.contains("method = bel"),
5148 "{notifications_msg}"
5149 );
5150 assert!(
5151 notifications_msg.contains("threshold_secs = 12"),
5152 "{notifications_msg}"
5153 );
5154 assert!(
5155 notifications_msg.contains("completion_sound = bell"),
5156 "{notifications_msg}"
5157 );
5158 }
5159
5160 #[test]
5161 fn config_command_shows_autodetected_tavily_key_source() {
5162 let temp_root = tempfile::tempdir().expect("isolated config dir");
5163 let _guard = EnvGuard::new(temp_root.path());
5164 let config_path = temp_root.path().join("custom-config.toml");
5165 fs::write(
5166 &config_path,
5167 r#"
5168 [search]
5169 api_key = "tvly-autodetected"
5170 "#,
5171 )
5172 .unwrap();
5173
5174 let mut app = create_test_app();
5175 app.config_path = Some(config_path);
5176
5177 let search = config_command(&mut app, Some("search.provider"));
5178 assert!(!search.is_error, "{:?}", search.message);
5179 let message = search.message.expect("search provider display");
5180 assert_eq!(message, "search.provider = tavily (source: tavily key)");
5181 assert!(
5182 !message.contains("TAVILY_API_KEY"),
5183 "a generic `tvly-` key must not be reported as the env var: {message}"
5184 );
5185 }
5186
5187 #[test]
5188 fn config_command_sets_search_prompt_suggestion_and_notifications() {
5189 let temp_root = tempfile::tempdir().expect("isolated config dir");
5190 let _guard = EnvGuard::new(temp_root.path());
5191 let config_path = temp_root.path().join("custom-config.toml");
5192
5193 let mut app = create_test_app();
5194 app.config_path = Some(config_path.clone());
5195
5196 let search = config_command(&mut app, Some("search.provider duckduckgo --save"));
5197 assert!(!search.is_error, "{:?}", search.message);
5198 match search.action {
5199 Some(AppAction::UpdateSearchProvider { provider }) => {
5200 assert_eq!(provider, SearchProvider::DuckDuckGo);
5201 }
5202 other => panic!("expected UpdateSearchProvider, got {other:?}"),
5203 }
5204
5205 let suggestion = config_command(&mut app, Some("prompt_suggestion true --save"));
5206 assert!(!suggestion.is_error, "{:?}", suggestion.message);
5207 match suggestion.action {
5208 Some(AppAction::UpdatePromptSuggestion { enabled }) => assert!(enabled),
5209 other => panic!("expected UpdatePromptSuggestion, got {other:?}"),
5210 }
5211
5212 let notifications = config_command(&mut app, Some("notifications method osc9 --save"));
5213 assert!(!notifications.is_error, "{:?}", notifications.message);
5214 match notifications.action {
5215 Some(AppAction::UpdateNotification {
5216 update: NotificationConfigUpdate::Method(method),
5217 }) => assert_eq!(method, NotificationMethod::Osc9),
5218 other => panic!("expected UpdateNotification method, got {other:?}"),
5219 }
5220
5221 let saved = fs::read_to_string(&config_path).unwrap();
5222 assert!(saved.contains("provider = \"duckduckgo\""), "{saved}");
5223 assert!(saved.contains("prompt_suggestion = true"), "{saved}");
5224 assert!(saved.contains("method = \"osc9\""), "{saved}");
5225
5226 let loaded = Config::load(Some(config_path), None).expect("reloaded config");
5227 assert_eq!(loaded.search_provider(), SearchProvider::DuckDuckGo);
5228 assert!(loaded.prompt_suggestion_enabled());
5229 assert_eq!(
5230 loaded.notifications_config().method,
5231 NotificationMethod::Osc9
5232 );
5233 }
5234
5235 #[test]
5236 fn session_only_notification_commands_emit_composable_field_deltas() {
5237 let temp_root = tempfile::tempdir().expect("isolated config dir");
5238 let _guard = EnvGuard::new(temp_root.path());
5239 let config_path = temp_root.path().join("custom-config.toml");
5240 fs::write(
5241 &config_path,
5242 "[notifications]\nmethod = \"bel\"\nthreshold_secs = 12\nquiet = false\n",
5243 )
5244 .expect("persisted notification config");
5245
5246 let mut app = create_test_app();
5247 app.config_path = Some(config_path);
5248 let mut live = NotificationsConfig {
5249 threshold_secs: 12,
5250 ..NotificationsConfig::default()
5251 };
5252
5253 for command in ["notifications method osc9", "notifications quiet true"] {
5254 let result = config_command(&mut app, Some(command));
5255 assert!(!result.is_error, "{:?}", result.message);
5256 let Some(AppAction::UpdateNotification { update }) = result.action else {
5257 panic!("expected notification field delta for {command}");
5258 };
5259 live.apply_update(update).unwrap();
5260 }
5261
5262 assert_eq!(live.method, NotificationMethod::Osc9);
5263 assert!(live.quiet);
5264 assert_eq!(live.threshold_secs, 12);
5265 }
5266
5267 #[test]
5268 fn config_command_rejects_invalid_search_and_notification_values() {
5269 let mut app = create_test_app();
5270 let search = config_command(&mut app, Some("search.provider not-a-backend"));
5271 assert!(search.is_error);
5272 let search_msg = search.message.unwrap();
5273 assert!(
5274 search_msg.contains("Can't use 'not-a-backend' for search.provider"),
5275 "{search_msg}"
5276 );
5277 assert!(search_msg.contains("firecrawl"), "{search_msg}");
5278
5279 let notifications = config_command(&mut app, Some("notifications method semaphore"));
5280 assert!(notifications.is_error);
5281 let notifications_msg = notifications.message.unwrap();
5282 assert!(
5283 notifications_msg.contains("Can't use 'semaphore' for notifications.method"),
5284 "{notifications_msg}"
5285 );
5286 assert!(notifications_msg.contains("osc9"), "{notifications_msg}");
5287 }
5288
5289 #[test]
5290 fn config_context_window_query_shows_override_and_effective_source() {
5291 let temp_root = env::temp_dir().join(format!(
5292 "codewhale-context-window-query-test-{}",
5293 std::process::id()
5294 ));
5295 fs::create_dir_all(&temp_root).unwrap();
5296 let _guard = EnvGuard::new(&temp_root);
5297 let config_path = temp_root.join("custom-config.toml");
5298 fs::write(
5299 &config_path,
5300 r#"
5301 provider = "moonshot"
5302 [providers.moonshot]
5303 model = "kimi-k3"
5304 context_window = 262144
5305 "#,
5306 )
5307 .unwrap();
5308 let mut app = create_test_app();
5309 app.config_path = Some(config_path);
5310 app.set_provider_identity_record(
5311 crate::config::Config::default()
5312 .resolve_provider_identity(ProviderKind::Moonshot.as_str())
5313 .expect("captured fixture provider"),
5314 );
5315 app.model = "kimi-k3".to_string();
5316 app.active_route_limits = Some(codewhale_config::route::RouteLimits {
5317 context_tokens: Some(262_144),
5318 ..Default::default()
5319 });
5320 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
5321
5322 let result = config_command(&mut app, Some("context_window"));
5323 let message = result.message.expect("context window message");
5324
5325 assert!(!result.is_error, "{message}");
5326 assert!(
5327 message.contains("262144 (effective 262144 from configured)"),
5328 "{message}"
5329 );
5330 }
5331
5332 #[test]
5333 fn config_command_base_url_without_save_requires_save() {
5334 let _lock = lock_test_env();
5335 let mut app = create_test_app();
5336 let result = config_command(&mut app, Some("base_url https://example.internal.local/v1"));
5337 assert!(result.is_error);
5338 let msg = result.message.unwrap();
5339
5340 assert!(
5341 msg.contains("base_url must be saved with --save"),
5342 "got {msg}"
5343 );
5344 }
5345
5346 #[test]
5347 fn config_command_base_url_reads_current_value_from_config() {
5348 let nanos = SystemTime::now()
5349 .duration_since(UNIX_EPOCH)
5350 .unwrap()
5351 .as_nanos();
5352 let temp_root = env::temp_dir().join(format!(
5353 "deepseek-tui-base-url-show-test-{}-{}",
5354 std::process::id(),
5355 nanos
5356 ));
5357 fs::create_dir_all(&temp_root).unwrap();
5358 let _guard = EnvGuard::new(&temp_root);
5359
5360 let config_path = temp_root.join(".deepseek").join("config.toml");
5361 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
5362 fs::write(
5363 &config_path,
5364 "base_url = \"https://api.from-config.local/v1\"\n",
5365 )
5366 .unwrap();
5367
5368 let mut app = create_test_app();
5369 let result = config_command(&mut app, Some("base_url"));
5370 let msg = result.message.unwrap();
5371
5372 assert_eq!(msg, "base_url = https://api.from-config.local/v1");
5373 }
5374
5375 #[test]
5376 fn config_command_base_url_reads_current_value_from_app_config_path() {
5377 let temp_root = env::temp_dir().join(format!(
5378 "deepseek-tui-base-url-app-config-path-test-{}",
5379 std::process::id()
5380 ));
5381 fs::create_dir_all(&temp_root).unwrap();
5382
5383 let config_path = temp_root.join("custom-config.toml");
5384 fs::write(
5385 &config_path,
5386 "base_url = \"https://api.from-app-path.local/v1\"\n",
5387 )
5388 .unwrap();
5389
5390 let mut app = create_test_app();
5391 app.config_path = Some(config_path.clone());
5392 let result = config_command(&mut app, Some("base_url"));
5393 let msg = result.message.unwrap();
5394
5395 assert_eq!(msg, "base_url = https://api.from-app-path.local/v1");
5396 }
5397
5398 #[test]
5399 fn config_command_base_url_save_persists_to_app_config_path() {
5400 let temp_root = env::temp_dir().join(format!(
5401 "deepseek-tui-base-url-save-app-path-test-{}",
5402 std::process::id()
5403 ));
5404 fs::create_dir_all(&temp_root).unwrap();
5405
5406 let config_path = temp_root.join("custom-config.toml");
5407
5408 let mut app = create_test_app();
5409 app.config_path = Some(config_path.clone());
5410 let result = config_command(
5411 &mut app,
5412 Some("base_url https://example.session.local/v1 --save"),
5413 );
5414 let msg = result.message.unwrap();
5415 let saved = fs::read_to_string(&config_path).unwrap();
5416
5417 assert_eq!(
5418 msg,
5419 format!(
5420 "base_url = https://example.session.local/v1 for deepseek (saved to {}; restart required)",
5421 config_path.display()
5422 )
5423 );
5424 let table: toml::Table = toml::from_str(&saved).unwrap();
5425 assert_eq!(
5426 table["providers"]["deepseek"]["base_url"].as_str(),
5427 Some("https://example.session.local/v1")
5428 );
5429 }
5430
5431 #[test]
5432 fn config_command_stream_chunk_timeout_session_query_uses_live_value() {
5433 let _lock = lock_test_env();
5434 let mut app = create_test_app();
5435
5436 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90"));
5437 assert!(!result.is_error);
5438 assert_eq!(app.stream_chunk_timeout_secs, 90);
5439 assert!(matches!(
5440 result.action,
5441 Some(AppAction::UpdateStreamChunkTimeout(90))
5442 ));
5443
5444 let query = config_command(&mut app, Some("stream_chunk_timeout_secs"));
5445 assert_eq!(
5446 query.message.as_deref(),
5447 Some("stream_chunk_timeout_secs = 90")
5448 );
5449 }
5450
5451 #[test]
5452 fn failed_save_leaves_live_config_state_untouched() {
5453 // C01-08: a `--save` that cannot write must not report failure over
5454 // live state that already moved.
5455 let temp = tempfile::tempdir().unwrap();
5456 let _guard = EnvGuard::new(temp.path());
5457 let blocker = temp.path().join("not-a-directory");
5458 fs::write(&blocker, "a file where the config directory should be").unwrap();
5459 let mut app = create_test_app();
5460 app.config_path = Some(blocker.join("config.toml"));
5461 let mcp_before = app.mcp_config_path.clone();
5462 app.mcp_reload_required = false;
5463 let timeout_before = app.stream_chunk_timeout_secs;
5464
5465 let result = set_config_value(&mut app, "mcp_config_path", "/elsewhere/mcp.json", true);
5466 assert!(result.is_error, "{:?}", result.message);
5467 assert_eq!(app.mcp_config_path, mcp_before);
5468 assert!(!app.mcp_reload_required);
5469
5470 let next_timeout = if timeout_before == 120 { "121" } else { "120" };
5471 let result = set_config_value(&mut app, "stream_chunk_timeout_secs", next_timeout, true);
5472 assert!(result.is_error, "{:?}", result.message);
5473 assert!(
5474 result.action.is_none(),
5475 "no engine update for an unsaved value"
5476 );
5477 assert_eq!(app.stream_chunk_timeout_secs, timeout_before);
5478 }
5479
5480 #[test]
5481 fn config_command_stream_chunk_timeout_save_overrides_canonical_value() {
5482 let nanos = SystemTime::now()
5483 .duration_since(UNIX_EPOCH)
5484 .unwrap()
5485 .as_nanos();
5486 let temp_root = env::temp_dir().join(format!(
5487 "codewhale-tui-stream-timeout-test-{}-{}",
5488 std::process::id(),
5489 nanos
5490 ));
5491 fs::create_dir_all(&temp_root).unwrap();
5492 let _guard = EnvGuard::new(&temp_root);
5493
5494 let config_path = temp_root.join("custom-config.toml");
5495 fs::write(
5496 &config_path,
5497 "[stream]\nchunk_timeout_secs=45\n[tui]\nstream_chunk_timeout_secs=30\n",
5498 )
5499 .unwrap();
5500 let mut app = create_test_app();
5501 app.config_path = Some(config_path.clone());
5502
5503 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save"));
5504 let msg = result.message.unwrap();
5505 let saved = fs::read_to_string(&config_path).unwrap();
5506
5507 assert_eq!(
5508 msg,
5509 format!(
5510 "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)",
5511 config_path.display()
5512 )
5513 );
5514 let reopened: Config = toml::from_str(&saved).unwrap();
5515 assert_eq!(
5516 reopened.stream_chunk_timeout_secs(),
5517 120,
5518 "saved value must survive reopening when a canonical value already existed"
5519 );
5520 assert_eq!(
5521 reopened.tui.unwrap().stream_chunk_timeout_secs,
5522 Some(30),
5523 "legacy compatibility input is preserved"
5524 );
5525 assert_eq!(app.stream_chunk_timeout_secs, 120);
5526 assert!(matches!(
5527 result.action,
5528 Some(AppAction::UpdateStreamChunkTimeout(120))
5529 ));
5530 }
5531
5532 /// The bottom-chrome row presets (#5950) apply on the next frame and
5533 /// `--save` writes the `[tui]` key; an unknown preset names the three.
5534 #[test]
5535 fn config_command_row_presets_apply_live_and_persist_to_tui_table() {
5536 use crate::config::ChromeRowPreset;
5537 let nanos = SystemTime::now()
5538 .duration_since(UNIX_EPOCH)
5539 .unwrap()
5540 .as_nanos();
5541 let temp_root = env::temp_dir().join(format!(
5542 "codewhale-tui-row-presets-test-{}-{}",
5543 std::process::id(),
5544 nanos
5545 ));
5546 fs::create_dir_all(&temp_root).unwrap();
5547 let _guard = EnvGuard::new(&temp_root);
5548 let config_path = temp_root.join("custom-config.toml");
5549 let mut app = create_test_app();
5550 app.config_path = Some(config_path.clone());
5551 assert_eq!(app.posture_bar, ChromeRowPreset::Full);
5552 assert_eq!(app.metrics_line, ChromeRowPreset::Compact);
5553
5554 let live = config_command(&mut app, Some("posture_bar compact"));
5555 assert!(!live.is_error, "{live:?}");
5556 assert_eq!(app.posture_bar, ChromeRowPreset::Compact);
5557 assert_eq!(
5558 live.message.as_deref(),
5559 Some("posture_bar = compact (SESSION)")
5560 );
5561 assert_eq!(
5562 config_command(&mut app, Some("posture_bar"))
5563 .message
5564 .as_deref(),
5565 Some("posture_bar = compact")
5566 );
5567
5568 let saved = config_command(&mut app, Some("metrics_line HIDDEN --save"));
5569 assert!(!saved.is_error, "{saved:?}");
5570 assert_eq!(app.metrics_line, ChromeRowPreset::Hidden);
5571 let body = fs::read_to_string(&config_path).unwrap();
5572 assert!(body.contains("[tui]"), "{body}");
5573 assert!(body.contains("metrics_line = \"hidden\""), "{body}");
5574 assert!(
5575 !body.contains("posture_bar"),
5576 "session-only value must not be saved: {body}"
5577 );
5578
5579 let bad = config_command(&mut app, Some("metrics_line tiny"));
5580 assert!(bad.is_error);
5581 assert!(
5582 bad.message
5583 .as_deref()
5584 .is_some_and(|m| m.contains("metrics_line. Try: full, compact, hidden")),
5585 "{bad:?}"
5586 );
5587 assert_eq!(
5588 app.metrics_line,
5589 ChromeRowPreset::Hidden,
5590 "a bad value changes nothing"
5591 );
5592 }
5593
5594 #[test]
5595 fn row_preset_save_failure_preserves_live_state_and_uses_current_locale() {
5596 let temp = tempfile::tempdir().unwrap();
5597 let _guard = EnvGuard::new(temp.path());
5598 let path = temp.path().join("config.toml");
5599 // A directory in place of the file fails on every supported OS.
5600 fs::create_dir(&path).unwrap();
5601 let mut app = create_test_app();
5602 app.config_path = Some(path);
5603 app.ui_locale = codewhale_localization::Locale::ZhHans;
5604 let before = (app.posture_bar, app.metrics_line);
5605 for key in ["posture_bar", "metrics_line"] {
5606 let result = config_command(&mut app, Some(&format!("{key} hidden --save")));
5607 assert!(result.is_error, "{result:?}");
5608 assert!(result.message.unwrap().contains("未能保存"));
5609 assert_eq!((app.posture_bar, app.metrics_line), before);
5610 let invalid = config_command(&mut app, Some(&format!("{key} tiny")));
5611 assert!(invalid.is_error);
5612 assert_eq!(
5613 invalid.message,
5614 CommandResult::error(
5615 tr(app.ui_locale, MessageId::ConfigCommandInvalidValue)
5616 .replace("{key}", key)
5617 .replace("{value}", "tiny")
5618 .replace("{choices}", "full, compact, hidden")
5619 )
5620 .message
5621 );
5622 }
5623 // A session-only change needs no writable file and uses the new locale.
5624 let result = config_command(&mut app, Some("posture_bar compact"));
5625 assert!(!result.is_error);
5626 assert_eq!(
5627 result.message.as_deref(),
5628 Some("posture_bar = compact (会话)")
5629 );
5630 }
5631
5632 #[test]
5633 fn row_preset_saved_in_active_profile_reloads_without_resetting_other_rows() {
5634 use crate::config::ChromeRowPreset;
5635 let temp = tempfile::tempdir().unwrap();
5636 let _guard = EnvGuard::new(temp.path());
5637 let path = temp.path().join("selected.toml");
5638 for owns_tui in [false, true] {
5639 let mut body = "# Keep this comment\n[tui]\nposture_bar = \"full\"\nmetrics_line = \"hidden\"\n[profiles.\"work.team\"]\nmax_subagents = 4\n".to_string();
5640 if owns_tui {
5641 body.push_str("[profiles.\"work.team\".tui]\nposture_bar = \"hidden\"\nmetrics_line = \"compact\"\n");
5642 }
5643 fs::write(&path, body).unwrap();
5644 let config = Config::load(Some(path.clone()), Some("work.team")).unwrap();
5645 let mut app = create_test_app_with_config(&config);
5646 app.config_path = Some(path.clone());
5647 app.config_profile = Some("work.team".to_string());
5648 let metrics_before = app.metrics_line;
5649 let result = config_command(&mut app, Some("posture_bar compact --save"));
5650 assert!(!result.is_error, "{result:?}");
5651 assert_eq!(app.posture_bar, ChromeRowPreset::Compact);
5652 let reloaded = Config::load(Some(path.clone()), Some("work.team")).unwrap();
5653 let restarted = create_test_app_with_config(&reloaded);
5654 assert_eq!(restarted.posture_bar, app.posture_bar);
5655 assert_eq!(restarted.metrics_line, metrics_before);
5656 let saved = fs::read_to_string(&path).unwrap();
5657 assert!(saved.starts_with("# Keep this comment"));
5658 let document: toml::Value = toml::from_str(&saved).unwrap();
5659 assert_eq!(
5660 document["profiles"]["work.team"].get("tui").is_some(),
5661 owns_tui
5662 );
5663 if owns_tui {
5664 assert_eq!(document["tui"]["posture_bar"].as_str(), Some("full"));
5665 }
5666 }
5667 }
5668
5669 #[test]
5670 fn config_command_stream_chunk_timeout_rejects_invalid_input() {
5671 let _lock = lock_test_env();
5672 let mut app = create_test_app();
5673
5674 let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc"));
5675 assert!(text.is_error);
5676 assert!(
5677 text.message
5678 .unwrap()
5679 .contains("stream_chunk_timeout_secs must be a whole number")
5680 );
5681
5682 let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601"));
5683 assert!(high.is_error);
5684 assert!(
5685 high.message
5686 .unwrap()
5687 .contains("stream_chunk_timeout_secs must be 0 or 1..=3600")
5688 );
5689 }
5690
5691 #[test]
5692 fn config_command_stream_chunk_timeout_zero_reports_effective_default() {
5693 let _lock = lock_test_env();
5694 let mut app = create_test_app();
5695
5696 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0"));
5697
5698 assert!(!result.is_error);
5699 assert_eq!(
5700 app.stream_chunk_timeout_secs,
5701 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
5702 );
5703 assert_eq!(
5704 result.message.as_deref(),
5705 Some(
5706 "stream_chunk_timeout_secs = 0 (default 900) (session only; affects subsequent turns in this session)"
5707 )
5708 );
5709 assert!(matches!(
5710 result.action,
5711 Some(AppAction::UpdateStreamChunkTimeout(
5712 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
5713 ))
5714 ));
5715 }
5716
5717 #[test]
5718 fn config_command_provider_url_token_plan_persists_provider_base_url() {
5719 let temp_root = env::temp_dir().join(format!(
5720 "codewhale-provider-url-save-app-path-test-{}",
5721 std::process::id()
5722 ));
5723 fs::create_dir_all(&temp_root).unwrap();
5724
5725 let config_path = temp_root.join("custom-config.toml");
5726
5727 let mut app = create_test_app();
5728 app.set_provider_identity_record(
5729 crate::config::Config::default()
5730 .resolve_provider_identity(ProviderKind::XiaomiMimo.as_str())
5731 .expect("captured fixture provider"),
5732 );
5733 app.config_path = Some(config_path.clone());
5734 let result = config_command(&mut app, Some("provider_url token-plan --save"));
5735 let msg = result.message.unwrap();
5736 let saved = fs::read_to_string(&config_path).unwrap();
5737
5738 assert_eq!(
5739 msg,
5740 format!(
5741 "provider_url = {} for xiaomi-mimo (saved to {}; restart required)",
5742 DEFAULT_XIAOMI_MIMO_BASE_URL,
5743 config_path.display()
5744 )
5745 );
5746 assert!(saved.contains("[providers.xiaomi_mimo]"));
5747 assert!(saved.contains(&format!("base_url = \"{DEFAULT_XIAOMI_MIMO_BASE_URL}\"")));
5748 }
5749
5750 #[test]
5751 fn config_command_provider_url_without_save_requires_save() {
5752 let _lock = lock_test_env();
5753 let mut app = create_test_app();
5754 app.api_provider = ProviderKind::XiaomiMimo;
5755 let result = config_command(&mut app, Some("provider_url token-plan"));
5756 assert!(result.is_error);
5757 let msg = result.message.unwrap();
5758
5759 assert!(
5760 msg.contains("provider_url must be saved with --save"),
5761 "got {msg}"
5762 );
5763 }
5764
5765 #[test]
5766 fn theme_command_accepts_grayscale_arg() {
5767 let nanos = SystemTime::now()
5768 .duration_since(UNIX_EPOCH)
5769 .unwrap()
5770 .as_nanos();
5771 let temp_root = env::temp_dir().join(format!(
5772 "codewhale-tui-theme-command-test-{}-{}",
5773 std::process::id(),
5774 nanos
5775 ));
5776 fs::create_dir_all(&temp_root).unwrap();
5777 let _guard = EnvGuard::new(&temp_root);
5778
5779 let mut app = create_test_app();
5780 let result = theme(&mut app, Some("grayscale"));
5781
5782 assert_eq!(result.message.unwrap(), "theme = grayscale (saved)");
5783 assert_eq!(app.theme_id, codewhale_palette::ThemeId::Grayscale);
5784 assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale);
5785 assert!(app.needs_redraw);
5786 }
5787
5788 #[test]
5789 fn theme_command_underwater_alias_selects_the_underwater_theme() {
5790 let nanos = SystemTime::now()
5791 .duration_since(UNIX_EPOCH)
5792 .unwrap()
5793 .as_nanos();
5794 let temp_root = env::temp_dir().join(format!(
5795 "codewhale-tui-theme-underwater-test-{}-{}",
5796 std::process::id(),
5797 nanos
5798 ));
5799 fs::create_dir_all(&temp_root).unwrap();
5800 let _guard = EnvGuard::new(&temp_root);
5801
5802 let mut app = create_test_app();
5803 for alias in ["underwater", "Deepsea", "deep-sea", "ombre"] {
5804 let result = theme(&mut app, Some(alias));
5805 assert!(!result.is_error, "{alias}: {:?}", result.message);
5806 assert_eq!(
5807 result.message.as_deref(),
5808 Some("theme = underwater (saved)"),
5809 "{alias}"
5810 );
5811 assert_eq!(
5812 app.theme_id,
5813 codewhale_palette::ThemeId::Underwater,
5814 "{alias}"
5815 );
5816 assert_eq!(app.ui_theme.name, "underwater", "{alias}");
5817 assert!(
5818 crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(),
5819 "{alias}: the underwater theme owns the painted field"
5820 );
5821 }
5822 }
5823
5824 #[test]
5825 fn underwater_theme_selection_updates_live_state_and_persists_one_field() {
5826 let temp_root = env::temp_dir().join(format!(
5827 "codewhale-tui-underwater-selection-test-{}-{}",
5828 std::process::id(),
5829 SystemTime::now()
5830 .duration_since(UNIX_EPOCH)
5831 .expect("clock")
5832 .as_nanos()
5833 ));
5834 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
5835 let _guard = EnvGuard::new(&temp_root);
5836 fs::write(
5837 temp_root.join(".deepseek").join("settings.toml"),
5838 "theme = \"light\"\nmax_input_history = 77\n",
5839 )
5840 .expect("seed settings");
5841
5842 let mut app = create_test_app();
5843 let result = set_config_value(&mut app, "theme", "underwater", true);
5844
5845 assert!(!result.is_error, "{:?}", result.message);
5846 assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater);
5847 let persisted = Settings::load_persisted().expect("persisted selection");
5848 assert_eq!(persisted.theme, "underwater");
5849 assert_eq!(
5850 persisted.max_input_history, 77,
5851 "the theme save must not overwrite unrelated settings"
5852 );
5853 }
5854
5855 #[test]
5856 fn custom_theme_selection_keeps_the_raw_selector_in_live_app_state() {
5857 let temp_root = env::temp_dir().join(format!(
5858 "codewhale-tui-custom-theme-selection-test-{}-{}",
5859 std::process::id(),
5860 SystemTime::now()
5861 .duration_since(UNIX_EPOCH)
5862 .expect("clock")
5863 .as_nanos()
5864 ));
5865 let _guard = EnvGuard::new(&temp_root);
5866 let themes = temp_root.join(".codewhale").join("themes");
5867 fs::create_dir_all(&themes).expect("themes dir");
5868 fs::write(
5869 themes.join("midnight.json"),
5870 r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
5871 )
5872 .expect("theme overlay");
5873
5874 let mut app = create_test_app();
5875 let result = set_config_value(&mut app, "theme", "custom:midnight", false);
5876
5877 assert!(!result.is_error, "{:?}", result.message);
5878 assert_eq!(app.theme_name, "custom:midnight");
5879 assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale);
5880 assert_eq!(
5881 app.ui_theme.accent_primary,
5882 ratatui::style::Color::Rgb(0x12, 0x34, 0x56)
5883 );
5884 }
5885
5886 #[test]
5887 fn invalid_theme_name_changes_nothing() {
5888 let temp_root = env::temp_dir().join(format!(
5889 "codewhale-tui-theme-preflight-test-{}-{}",
5890 std::process::id(),
5891 SystemTime::now()
5892 .duration_since(UNIX_EPOCH)
5893 .expect("clock")
5894 .as_nanos()
5895 ));
5896 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
5897 let _guard = EnvGuard::new(&temp_root);
5898 fs::write(
5899 temp_root.join(".deepseek").join("settings.toml"),
5900 "theme = \"light\"\n",
5901 )
5902 .expect("seed settings");
5903
5904 let mut app = create_test_app();
5905 let original_theme = app.theme_id;
5906 let result = set_config_value(&mut app, "theme", "kelp", true);
5907
5908 assert!(result.is_error);
5909 assert_eq!(app.theme_id, original_theme);
5910 let persisted = Settings::load_persisted().expect("unchanged persisted settings");
5911 assert_eq!(persisted.theme, "light");
5912 }
5913
5914 #[test]
5915 fn explicit_default_background_override_survives_theme_preview() {
5916 let temp_root = env::temp_dir().join(format!(
5917 "codewhale-tui-background-override-test-{}-{}",
5918 std::process::id(),
5919 SystemTime::now()
5920 .duration_since(UNIX_EPOCH)
5921 .expect("clock")
5922 .as_nanos()
5923 ));
5924 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
5925 let _guard = EnvGuard::new(&temp_root);
5926 fs::write(
5927 temp_root.join(".deepseek").join("settings.toml"),
5928 "theme = \"solarized-light\"\nbackground_color = \"#fdf6e3\"\n",
5929 )
5930 .expect("seed settings");
5931
5932 let mut app = create_test_app();
5933 let explicit_base3 = ratatui::style::Color::Rgb(0xfd, 0xf6, 0xe3);
5934 assert_eq!(app.background_color_override, Some(explicit_base3));
5935
5936 let result = set_config_value(&mut app, "theme", "dark", false);
5937
5938 assert!(!result.is_error, "{:?}", result.message);
5939 assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale);
5940 assert_eq!(app.background_color_override, Some(explicit_base3));
5941 assert_eq!(app.ui_theme.surface_bg, explicit_base3);
5942 assert!(
5943 crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none(),
5944 "only the underwater theme owns a painted field"
5945 );
5946 }
5947
5948 #[test]
5949 fn underwater_theme_keeps_its_field_under_a_background_override() {
5950 let temp_root = env::temp_dir().join(format!(
5951 "codewhale-tui-underwater-override-test-{}-{}",
5952 std::process::id(),
5953 SystemTime::now()
5954 .duration_since(UNIX_EPOCH)
5955 .expect("clock")
5956 .as_nanos()
5957 ));
5958 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
5959 let _guard = EnvGuard::new(&temp_root);
5960
5961 let mut app = create_test_app();
5962 let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26);
5963 let background = set_config_value(&mut app, "background_color", "#1a1b26", false);
5964 assert!(!background.is_error, "{:?}", background.message);
5965
5966 let preview = set_config_value(&mut app, "theme", "underwater", false);
5967 assert!(!preview.is_error, "{:?}", preview.message);
5968 assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater);
5969 assert_eq!(app.background_color_override, Some(custom));
5970 assert_eq!(app.ui_theme.surface_bg, custom);
5971 assert!(
5972 crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(),
5973 "the underwater theme's field survives a background override"
5974 );
5975 }
5976
5977 #[test]
5978 fn session_only_background_override_survives_theme_preview() {
5979 let temp_root = env::temp_dir().join(format!(
5980 "codewhale-tui-session-background-test-{}-{}",
5981 std::process::id(),
5982 SystemTime::now()
5983 .duration_since(UNIX_EPOCH)
5984 .expect("clock")
5985 .as_nanos()
5986 ));
5987 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
5988 let _guard = EnvGuard::new(&temp_root);
5989 fs::write(
5990 temp_root.join(".deepseek").join("settings.toml"),
5991 "theme = \"solarized-light\"\n",
5992 )
5993 .expect("seed settings");
5994
5995 let mut app = create_test_app();
5996 let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26);
5997 let background = set_config_value(&mut app, "background_color", "#1a1b26", false);
5998 assert!(!background.is_error, "{:?}", background.message);
5999 assert_eq!(app.background_color_override, Some(custom));
6000
6001 let preview = set_config_value(&mut app, "theme", "dark", false);
6002 assert!(!preview.is_error, "{:?}", preview.message);
6003 assert_eq!(app.background_color_override, Some(custom));
6004 assert_eq!(app.ui_theme.surface_bg, custom);
6005
6006 let solarized_preview = set_config_value(&mut app, "theme", "solarized-light", false);
6007 assert!(
6008 !solarized_preview.is_error,
6009 "{:?}",
6010 solarized_preview.message
6011 );
6012 assert_eq!(app.background_color_override, Some(custom));
6013 assert_eq!(app.ui_theme.surface_bg, custom);
6014 assert!(crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none());
6015
6016 let saved_theme = set_config_value(&mut app, "theme", "dark", true);
6017 assert!(!saved_theme.is_error, "{:?}", saved_theme.message);
6018 assert_eq!(app.background_color_override, Some(custom));
6019 assert_eq!(app.ui_theme.surface_bg, custom);
6020 let persisted = Settings::load_persisted().expect("persisted settings");
6021 assert_eq!(persisted.theme, "dark");
6022 assert_eq!(
6023 persisted.background_color, None,
6024 "saving a theme must not persist the session-only background"
6025 );
6026 }
6027
6028 #[test]
6029 fn set_theme_save_updates_live_app_and_persists() {
6030 let nanos = SystemTime::now()
6031 .duration_since(UNIX_EPOCH)
6032 .unwrap()
6033 .as_nanos();
6034 let temp_root = env::temp_dir().join(format!(
6035 "codewhale-tui-theme-save-test-{}-{}",
6036 std::process::id(),
6037 nanos
6038 ));
6039 fs::create_dir_all(&temp_root).unwrap();
6040 let _guard = EnvGuard::new(&temp_root);
6041
6042 let mut app = create_test_app();
6043 let result = config_command(&mut app, Some("theme grayscale --save"));
6044 let msg = result.message.unwrap();
6045
6046 assert_eq!(msg, "theme = grayscale (saved)");
6047 assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale);
6048
6049 let settings_path = Settings::path().unwrap();
6050 let saved = fs::read_to_string(settings_path).unwrap();
6051 assert!(saved.contains("theme = \"grayscale\""));
6052 }
6053
6054 #[test]
6055 fn unrelated_save_does_not_persist_no_animations_runtime_overlay() {
6056 let temp_root = env::temp_dir().join(format!(
6057 "codewhale-no-animations-save-test-{}-{}",
6058 std::process::id(),
6059 SystemTime::now()
6060 .duration_since(UNIX_EPOCH)
6061 .expect("clock")
6062 .as_nanos()
6063 ));
6064 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
6065 let _guard = EnvGuard::new(&temp_root);
6066 fs::write(
6067 temp_root.join(".deepseek").join("settings.toml"),
6068 "low_motion = false\nfancy_animations = true\ntheme = \"system\"\n",
6069 )
6070 .expect("seed settings");
6071 // Safety: test-only environment mutation is serialized by EnvGuard.
6072 unsafe {
6073 env::set_var("NO_ANIMATIONS", "1");
6074 }
6075
6076 let mut app = create_test_app();
6077 assert!(app.low_motion, "runtime overlay should reduce motion");
6078 assert!(
6079 !app.fancy_animations,
6080 "runtime overlay should disable ocean animations"
6081 );
6082
6083 let result = set_config_value(&mut app, "theme", "grayscale", true);
6084 assert!(!result.is_error, "{:?}", result.message);
6085 let saved = Settings::load_persisted().expect("persisted settings");
6086 assert_eq!(saved.theme, "grayscale");
6087 assert!(
6088 !saved.low_motion,
6089 "NO_ANIMATIONS must not become a saved preference"
6090 );
6091 assert!(
6092 saved.fancy_animations,
6093 "NO_ANIMATIONS must not overwrite the saved animation preference"
6094 );
6095 assert!(app.low_motion);
6096 assert!(!app.fancy_animations);
6097 }
6098
6099 #[test]
6100 fn preset_save_does_not_persist_runtime_environment_overlays() {
6101 let temp_root = env::temp_dir().join(format!(
6102 "codewhale-preset-env-overlay-test-{}-{}",
6103 std::process::id(),
6104 SystemTime::now()
6105 .duration_since(UNIX_EPOCH)
6106 .expect("clock")
6107 .as_nanos()
6108 ));
6109 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
6110 let _guard = EnvGuard::new(&temp_root);
6111 fs::write(
6112 temp_root.join(".deepseek").join("settings.toml"),
6113 "low_motion = false\nfancy_animations = true\nsynchronized_output = \"auto\"\n",
6114 )
6115 .expect("seed settings");
6116 // NO_ANIMATIONS exercises the reported path. Ptyxis supplies an
6117 // unrelated effective-only field, making an accidental
6118 // apply_env_overrides()+save observable even though the calm preset
6119 // intentionally selects reduced motion itself.
6120 unsafe {
6121 env::set_var("NO_ANIMATIONS", "1");
6122 env::set_var("PTYXIS_VERSION", "50.0");
6123 }
6124
6125 let mut app = create_test_app();
6126 let result = config_command(&mut app, Some("preset calm --save"));
6127 assert!(!result.is_error, "{:?}", result.message);
6128
6129 let saved = Settings::load_persisted().expect("persisted settings");
6130 assert!(saved.low_motion, "calm preset should save reduced motion");
6131 assert!(
6132 !saved.fancy_animations,
6133 "calm preset should save static ocean chrome"
6134 );
6135 assert_eq!(
6136 saved.synchronized_output, "auto",
6137 "Ptyxis runtime override must not leak into a preset save"
6138 );
6139 }
6140
6141 #[test]
6142 fn config_approval_mode_valid_values() {
6143 let dir = tempfile::tempdir().expect("isolated config dir");
6144 let mut app = create_test_app();
6145 app.config_path = Some(dir.path().join("config.toml"));
6146 // Test auto
6147 let result = config_command(&mut app, Some("approval_mode auto"));
6148 assert!(result.message.is_some());
6149 assert_eq!(app.approval_mode, ApprovalMode::Auto);
6150
6151 // Test suggest
6152 let result = config_command(&mut app, Some("approval_mode suggest"));
6153 assert!(result.message.is_some());
6154 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
6155
6156 // Test never
6157 let result = config_command(&mut app, Some("approval_mode never"));
6158 assert!(result.message.is_some());
6159 assert_eq!(app.approval_mode, ApprovalMode::Never);
6160 }
6161
6162 #[test]
6163 fn config_approval_mode_save_persists_top_level_policy() {
6164 let temp_root = env::temp_dir().join(format!(
6165 "codewhale-approval-policy-save-test-{}",
6166 std::process::id()
6167 ));
6168 fs::create_dir_all(&temp_root).unwrap();
6169 let _guard = EnvGuard::new(&temp_root);
6170 let config_path = temp_root.join("custom-config.toml");
6171
6172 let mut app = create_test_app();
6173 app.config_path = Some(config_path.clone());
6174 let result = config_command(&mut app, Some("approval_mode suggest --save"));
6175 let msg = result.message.unwrap();
6176 let saved = fs::read_to_string(&config_path).unwrap();
6177
6178 assert!(!result.is_error);
6179 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
6180 assert_eq!(
6181 msg,
6182 format!(
6183 "approval_mode = Ask (saved to {} as approval_policy = \"on-request\")",
6184 config_path.display()
6185 )
6186 );
6187 assert!(saved.contains("approval_policy = \"on-request\""));
6188
6189 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
6190 assert_eq!(loaded.approval_policy.as_deref(), Some("on-request"));
6191
6192 let mut restarted = create_test_app_with_config(&loaded);
6193 restarted.config_path = Some(config_path.clone());
6194 assert!(restarted.approval_policy_locked());
6195 assert!(!restarted.approval_policy_requirements_managed());
6196 let changed = config_command(&mut restarted, Some("approval_mode auto --save"));
6197 assert!(!changed.is_error, "{:?}", changed.message);
6198 assert_eq!(
6199 changed.action,
6200 Some(AppAction::ApprovalPolicyPersisted {
6201 policy: Some("auto".to_string())
6202 })
6203 );
6204 assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
6205 let reloaded = Config::load(Some(config_path), None).unwrap();
6206 assert_eq!(reloaded.approval_policy.as_deref(), Some("auto"));
6207 }
6208
6209 #[test]
6210 fn config_approval_policy_can_return_to_saved_tui_permission_default() {
6211 let temp_root = env::temp_dir().join(format!(
6212 "codewhale-approval-policy-tui-default-test-{}",
6213 std::process::id()
6214 ));
6215 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
6216 let _guard = EnvGuard::new(&temp_root);
6217 let config_path = temp_root.join("custom-config.toml");
6218 fs::write(&config_path, "# keep\napproval_policy = \"auto\"\n").unwrap();
6219 fs::write(
6220 temp_root.join(".deepseek").join("settings.toml"),
6221 "permission_posture = \"full-access\"\n",
6222 )
6223 .unwrap();
6224 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
6225 let mut app = create_test_app_with_config(&loaded);
6226 app.config_path = Some(config_path.clone());
6227
6228 let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true);
6229
6230 assert!(!result.is_error, "{:?}", result.message);
6231 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
6232 assert!(!app.approval_policy_locked());
6233 assert_eq!(
6234 result.action,
6235 Some(AppAction::ApprovalPolicyPersisted { policy: None })
6236 );
6237 let saved = fs::read_to_string(config_path).unwrap();
6238 assert!(saved.contains("# keep"));
6239 assert!(!saved.contains("approval_policy"));
6240 }
6241
6242 #[test]
6243 fn config_approval_policy_full_access_adopts_tui_posture_and_releases_root_override() {
6244 let temp_root = env::temp_dir().join(format!(
6245 "codewhale-approval-policy-full-access-test-{}",
6246 std::process::id()
6247 ));
6248 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
6249 let _guard = EnvGuard::new(&temp_root);
6250 let config_path = temp_root.join("custom-config.toml");
6251 fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n").unwrap();
6252 fs::write(
6253 temp_root.join(".deepseek").join("settings.toml"),
6254 "permission_posture = \"ask\"\n",
6255 )
6256 .unwrap();
6257 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
6258 let mut app = create_test_app_with_config(&loaded);
6259 app.config_path = Some(config_path.clone());
6260 // The production constructor receives the path up front and marks a
6261 // user-owned root policy editable. This focused fixture attaches the
6262 // path after construction, so mirror that resolved ownership here.
6263 app.mark_approval_policy_locked();
6264 assert!(app.approval_policy_locked());
6265
6266 let result = set_config_value(&mut app, "approval_policy", "full-access", true);
6267
6268 assert!(!result.is_error, "{:?}", result.message);
6269 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
6270 assert!(!app.approval_policy_locked());
6271 assert_eq!(
6272 result.action,
6273 Some(AppAction::ApprovalPolicyPersisted { policy: None })
6274 );
6275 let saved_config = fs::read_to_string(config_path).unwrap();
6276 assert!(saved_config.contains("# keep"));
6277 assert!(!saved_config.contains("approval_policy"));
6278 let saved_settings = Settings::load_persisted().expect("saved TUI settings");
6279 assert_eq!(
6280 saved_settings.permission_posture.as_deref(),
6281 Some("full-access")
6282 );
6283 }
6284
6285 #[test]
6286 fn config_approval_mode_invalid_value() {
6287 let dir = tempfile::tempdir().expect("isolated config dir");
6288 let mut app = create_test_app();
6289 app.config_path = Some(dir.path().join("config.toml"));
6290 let result = config_command(&mut app, Some("approval_mode invalid"));
6291 assert!(result.message.is_some());
6292 let msg = result.message.unwrap();
6293 assert!(msg.contains("Invalid approval_mode"));
6294 }
6295
6296 #[test]
6297 fn config_without_save_flag() {
6298 let _lock = lock_test_env();
6299 let mut app = create_test_app();
6300 let result = config_command(&mut app, Some("auto_compact true"));
6301 assert!(result.message.is_some());
6302 let msg = result.message.unwrap();
6303 assert!(msg.contains("(session only"));
6304 }
6305
6306 #[test]
6307 fn config_threshold_enables_and_updates_live_auto_compaction() {
6308 let _lock = lock_test_env();
6309 let mut app = create_test_app();
6310 app.auto_compact = false;
6311 app.auto_compact_user_configured = false;
6312
6313 let result = config_command(&mut app, Some("auto_compact_threshold_percent 65"));
6314
6315 assert!(!result.is_error, "{:?}", result.message);
6316 assert!(app.auto_compact);
6317 assert!(app.auto_compact_user_configured);
6318 assert_eq!(app.auto_compact_threshold_percent, 65.0);
6319 assert_eq!(
6320 app.compact_threshold,
6321 crate::route_budget::compaction_threshold_for_route_at_percent(
6322 app.api_provider,
6323 app.effective_model_for_budget(),
6324 app.active_route_limits,
6325 65.0,
6326 )
6327 );
6328 assert!(matches!(
6329 result.action,
6330 Some(AppAction::UpdateCompaction(_))
6331 ));
6332 }
6333
6334 #[test]
6335 fn config_composer_border_updates_live_app() {
6336 let _lock = lock_test_env();
6337 let mut app = create_test_app();
6338 app.composer_border = true;
6339
6340 let result = config_command(&mut app, Some("composer_border false"));
6341
6342 assert!(result.message.is_some());
6343 assert!(!app.composer_border);
6344 assert!(app.needs_redraw);
6345 }
6346
6347 #[test]
6348 fn config_composer_multiline_mode_updates_live_app() {
6349 let _lock = lock_test_env();
6350 let mut app = create_test_app();
6351 app.composer_multiline_mode = false;
6352
6353 let result = config_command(&mut app, Some("composer_multiline_mode true"));
6354
6355 assert!(!result.is_error, "{:?}", result.message);
6356 assert!(app.composer_multiline_mode);
6357 assert!(app.needs_redraw);
6358 }
6359
6360 #[tokio::test]
6361 async fn trust_only_persists_with_save() {
6362 let tmp = tempfile::tempdir().unwrap();
6363 let _guard = EnvGuard::new(tmp.path());
6364 let config_path = tmp.path().join("config.toml");
6365 let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
6366 let workspace = tmp.path().join("workspace");
6367 let commands = workspace.join(".claude/commands");
6368 let skills = workspace.join(".claude/skills/review-example");
6369 fs::create_dir_all(&commands).unwrap();
6370 fs::create_dir_all(&skills).unwrap();
6371 fs::write(commands.join("review-example.md"), "Review the example").unwrap();
6372 fs::write(
6373 skills.join("SKILL.md"),
6374 "---\nname: review-example\ndescription: Review the example\n---\nRead the example.",
6375 )
6376 .unwrap();
6377 let mut app = create_test_app();
6378 app.workspace = workspace.clone();
6379 app.trust_mode = false;
6380 for trusted in [false, true, false] {
6381 if trusted || app.trust_mode {
6382 let result = trust(
6383 &mut app,
6384 Some(if trusted { "on --save" } else { "off --save" }),
6385 );
6386 assert_eq!(
6387 result.action,
6388 Some(AppAction::SetWorkspaceTrust {
6389 trusted,
6390 save: true
6391 })
6392 );
6393 set_workspace_trust(&mut app, trusted, true).await.unwrap();
6394 }
6395 let saved = fs::read(&config_path).ok();
6396 for session_trusted in [true, false] {
6397 let result = trust(&mut app, Some(if session_trusted { "on" } else { "off" }));
6398 assert_eq!(
6399 result.action,
6400 Some(AppAction::SetWorkspaceTrust {
6401 trusted: session_trusted,
6402 save: false
6403 })
6404 );
6405 set_workspace_trust(&mut app, session_trusted, false)
6406 .await
6407 .unwrap();
6408 assert_eq!(app.trust_mode, session_trusted);
6409 assert_eq!(
6410 fs::read(&config_path).ok(),
6411 saved,
6412 "session toggle must not write config"
6413 );
6414 assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted);
6415 }
6416 app.trust_mode = trusted;
6417 assert!(trust(&mut app, Some("on --typo")).is_error);
6418 assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted);
6419 crate::commands::user_registry::with_registry_for_workspace(
6420 Some(&workspace),
6421 |registry| {
6422 assert_eq!(registry.get("review-example").is_some(), trusted);
6423 },
6424 );
6425 assert_eq!(
6426 crate::skills::discover_in_workspace(&workspace)
6427 .get("review-example")
6428 .is_some(),
6429 trusted
6430 );
6431 }
6432 // A failed write must not grant trust in memory.
6433 fs::create_dir_all(tmp.path().join("bad-config")).unwrap();
6434 let _bad_config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("bad-config"));
6435 assert!(set_workspace_trust(&mut app, true, true).await.is_err());
6436 assert!(!app.trust_mode);
6437 app.trust_mode = true;
6438 assert!(set_workspace_trust(&mut app, false, true).await.is_err());
6439 assert!(!app.trust_mode);
6440 }
6441
6442 #[test]
6443 fn test_trust_status_default_lists_state() {
6444 let mut app = create_test_app();
6445 let result = trust(&mut app, None);
6446 let msg = result.message.expect("status message");
6447 assert!(msg.contains("Workspace trust mode"));
6448 }
6449
6450 #[test]
6451 fn test_trust_add_requires_path() {
6452 let mut app = create_test_app();
6453 let result = trust(&mut app, Some("add"));
6454 let msg = result.message.expect("error message");
6455 assert!(msg.starts_with("Error:"), "got {msg:?}");
6456 }
6457
6458 #[test]
6459 fn test_logout_clears_api_key_state() {
6460 let nanos = SystemTime::now()
6461 .duration_since(UNIX_EPOCH)
6462 .unwrap()
6463 .as_nanos();
6464 let temp_root = env::temp_dir().join(format!(
6465 "codewhale-tui-logout-test-{}-{}",
6466 std::process::id(),
6467 nanos
6468 ));
6469 fs::create_dir_all(&temp_root).unwrap();
6470 let _guard = EnvGuard::new(&temp_root);
6471
6472 let config_path = temp_root.join(".deepseek").join("config.toml");
6473 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
6474 fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
6475
6476 let mut app = create_test_app();
6477 let result = logout(&mut app);
6478 assert!(result.message.is_some());
6479 assert_eq!(app.onboarding, OnboardingState::Provider);
6480 assert!(app.onboarding_needs_api_key);
6481 assert!(app.onboarding_missing_key_recovery);
6482 assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
6483
6484 let updated = fs::read_to_string(config_path).unwrap();
6485 assert!(!updated.contains("api_key"));
6486 }
6487
6488 #[test]
6489 fn logout_clears_only_exact_named_custom_provider_key() {
6490 let nanos = SystemTime::now()
6491 .duration_since(UNIX_EPOCH)
6492 .unwrap()
6493 .as_nanos();
6494 let temp_root = env::temp_dir().join(format!(
6495 "codewhale-custom-logout-test-{}-{}",
6496 std::process::id(),
6497 nanos
6498 ));
6499 fs::create_dir_all(&temp_root).unwrap();
6500 let _guard = EnvGuard::new(&temp_root);
6501 let config_path = temp_root.join(".deepseek").join("config.toml");
6502 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
6503 fs::write(
6504 &config_path,
6505 "[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n",
6506 )
6507 .unwrap();
6508 let mut app = create_test_app();
6509 app.set_provider_identity(ProviderKind::Custom, "custom-a");
6510
6511 let result = logout(&mut app);
6512
6513 assert!(result.message.is_some());
6514 let updated = fs::read_to_string(config_path).unwrap();
6515 assert!(!updated.contains("a-key"), "{updated}");
6516 assert!(updated.contains("b-key"), "{updated}");
6517 }
6518
6519 #[test]
6520 fn named_custom_provider_url_write_fails_closed() {
6521 let mut app = create_test_app();
6522 app.set_provider_identity(ProviderKind::Custom, "custom-a");
6523
6524 let result = config_command(
6525 &mut app,
6526 Some("provider_url http://127.0.0.1:18181/v1 --save"),
6527 );
6528 let message = result.message.expect("error message");
6529
6530 assert!(result.is_error);
6531 assert!(result.action.is_none());
6532 assert!(
6533 message.contains("[providers.custom-a]") && message.contains("missing"),
6534 "{message}"
6535 );
6536 }
6537 }
6538
6538 lines RUST