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