返回 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, SubagentsConfig,
10 XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, normalize_custom_model_id,
11 normalize_model_name_for_provider, validate_route,
12 };
13 use crate::config_persistence::{
14 persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key,
15 persist_subagents_bool_key, persist_subagents_integer_key, persist_tui_integer_key,
16 persist_unset_root_key,
17 };
18 use crate::config_ui::{ConfigUiMode, parse_mode};
19 use crate::localization::{MessageId, resolve_locale};
20 use crate::settings::Settings;
21 use crate::tui::app::{
22 App, AppAction, AppMode, OnboardingState, ReasoningEffort, SettingSelection, VimMode,
23 };
24 use crate::tui::approval::ApprovalMode;
25 use anyhow::Result;
26 use std::path::{Path, PathBuf};
27
28 /// Open the interactive config editor.
29 ///
30 /// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action),
31 /// preserving the v0.8.4 behaviour. `/config tui` opens the new
32 /// schemaui-driven TUI editor; `/config web` launches the web editor (only
33 /// available in builds compiled with the `web` feature).
34 pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
35 let mode = match parse_mode(arg) {
36 Ok(mode) => mode,
37 Err(err) => return CommandResult::error(err),
38 };
39 if mode == ConfigUiMode::Web && !cfg!(feature = "web") {
40 return CommandResult::error(
41 "This build does not include the web config UI. Rebuild with the `web` feature.",
42 );
43 }
44 let action = match mode {
45 ConfigUiMode::Native => AppAction::OpenConfigView,
46 ConfigUiMode::Tui | ConfigUiMode::Web => AppAction::OpenConfigEditor(mode),
47 };
48 CommandResult::action(action)
49 }
50
51 /// Dispatch `/config` with optional args.
52 ///
53 /// - `/config` (no args) — opens the schemaui-driven TUI editor.
54 /// - `/config tui` / `/config web` / `/config native` — open a specific
55 /// editor mode (web requires the `web` build feature).
56 /// - `/config ask-rules` — compatibility entry for `/permissions`.
57 /// - `/config <key>` — shows the current value of a setting.
58 /// - `/config <key> <value>` — sets a runtime value (session only, add --save to persist).
59 pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
60 let raw = arg.map(str::trim).unwrap_or("");
61 if raw.is_empty() {
62 return show_config(app, None);
63 }
64 if matches!(
65 raw.to_ascii_lowercase().as_str(),
66 "audit" | "editability" | "editable" | "status"
67 ) {
68 return config_editability_audit(app);
69 }
70 let mut raw_words = raw.splitn(2, char::is_whitespace);
71 let first_word = raw_words.next();
72 if first_word.is_some_and(is_ask_rules_config_token) {
73 let rest = raw_words.next().unwrap_or("").trim();
74 return super::permissions::permissions_command(app, Some(rest));
75 }
76 if first_word.is_some_and(|token| token.eq_ignore_ascii_case("subagents")) {
77 let rest = raw_words.next().unwrap_or("").trim();
78 return subagents_config_command(app, rest);
79 }
80 // `/config preset <name> [--save|-s]` — apply a bundled settings preset (#3478).
81 if first_word.is_some_and(|token| token.eq_ignore_ascii_case("preset")) {
82 let rest = raw_words.next().unwrap_or("").trim();
83 return config_preset_command(app, rest);
84 }
85 let parts: Vec<&str> = raw.splitn(2, ' ').collect();
86 if parts.len() == 1 {
87 // Single arg: editor-mode shortcut OR show-value request.
88 let token = parts[0];
89 if matches!(
90 token.to_ascii_lowercase().as_str(),
91 "tui" | "web" | "native"
92 ) {
93 return show_config(app, Some(token));
94 }
95 // `/config <key>` — show current value
96 show_single_setting(app, token)
97 } else {
98 // `/config <key> <value> [--save|-s]` — set value, optionally persist
99 let raw_value = parts[1];
100 let persist = raw_value.ends_with(" --save") || raw_value.ends_with(" -s");
101 let value = if persist {
102 raw_value
103 .strip_suffix(" --save")
104 .or_else(|| raw_value.strip_suffix(" -s"))
105 .unwrap_or(raw_value)
106 } else {
107 raw_value
108 };
109 set_config_value(app, parts[0], value, persist)
110 }
111 }
112
113 /// Reject a preset bundle *before* anything is written, returning the message
114 /// to show, or `None` when every field can be applied.
115 ///
116 /// The bundle is persisted in one transaction and then mirrored field by field
117 /// into the live session. A per-field refusal during that mirror pass therefore
118 /// arrives *after* the file has already been rewritten — the user gets an error
119 /// and a saved file, which is the partial apply this preflight exists to make
120 /// impossible. Both refusals a field can raise are knowable up front:
121 ///
122 /// 1. A live-route key while a turn is running (#2982).
123 /// 2. A value the setter would reject, checked against a throwaway `Settings`
124 /// so the real file is never touched by the check.
125 fn preset_preflight(app: &App, fields: &[(&str, &str)]) -> Option<String> {
126 for (key, value) in fields {
127 if app.is_loading
128 && let Some(subject) = live_route_setting_subject(&key.to_lowercase())
129 {
130 return Some(app.setting_locked_message(subject));
131 }
132 if let Err(e) = Settings::default().set(key, value) {
133 return Some(format!("Failed to apply preset field {key}={value}: {e}"));
134 }
135 }
136 None
137 }
138
139 /// Apply a bundled settings preset, e.g. `/config preset calm [--save]` (#3478).
140 ///
141 /// The preset is applied to the live session through the same per-key setter a
142 /// single `/config <key> <value>` uses, so app state mirroring and (with
143 /// `--save`) persistence stay consistent. The preset name is validated before
144 /// any field is touched.
145 fn config_preset_command(app: &mut App, rest: &str) -> CommandResult {
146 let tokens: Vec<&str> = rest.split_whitespace().collect();
147 let persist = matches!(tokens.last(), Some(&"--save") | Some(&"-s"));
148 let name = tokens.first().copied().unwrap_or("");
149 if name.is_empty() || name.starts_with('-') {
150 return CommandResult::message(
151 "Usage: /config preset <name> [--save]. Available presets: calm.",
152 );
153 }
154
155 let Some(fields) = crate::settings::preset_fields(name) else {
156 return CommandResult::error(format!("Unknown preset '{name}'. Available presets: calm."));
157 };
158
159 if let Some(refusal) = preset_preflight(app, fields) {
160 return CommandResult::error(refusal);
161 }
162
163 // Persist the whole bundle atomically when requested (one load/apply/save),
164 // now that every field is known to be applicable.
165 if persist {
166 // `Settings::transact` is what makes "one load/apply/save" true against
167 // the *other* writers in this process, not just against a second preset
168 // apply: an unsynchronized load/save pair here would write back a
169 // pre-image that reverts a concurrent mode/thinking/posture write.
170 if let Err(e) = Settings::transact(|settings| settings.apply_preset(name)) {
171 return CommandResult::error(format!("Failed to save settings: {e}"));
172 }
173 }
174
175 // Mirror the bundle into the live session via the per-key setter (the
176 // persisted write, if any, already happened atomically above, so this pass
177 // is session-only).
178 let mut applied = Vec::with_capacity(fields.len());
179 for (key, value) in fields {
180 let result = set_config_value(app, key, value, false);
181 if result.is_error {
182 let message = result
183 .message
184 .unwrap_or_else(|| "unknown apply error".to_string());
185 return CommandResult::error(format!(
186 "Failed to apply preset field {key}={value}: {message}"
187 ));
188 }
189 applied.push(format!("{key}={value}"));
190 }
191
192 let suffix = if persist {
193 " (saved)"
194 } else {
195 " (session only — add --save to persist)"
196 };
197 CommandResult::message(format!(
198 "Applied '{name}' transcript preset{suffix}: {}. Thinking stays visible and tool runs stay expandable.",
199 applied.join(", ")
200 ))
201 }
202
203 /// Show the current value of a single setting.
204 fn config_context_window_override(app: &App) -> Option<u32> {
205 let mut config = Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?;
206 config.provider = Some(app.provider_identity_for_persistence().to_string());
207 config.context_window_for_provider_config(app.api_provider)
208 }
209
210 fn show_single_setting(app: &App, key: &str) -> CommandResult {
211 let key = key.to_lowercase();
212 if let Some(subagent_key) = key.strip_prefix("subagents.") {
213 return show_subagents_setting(app, subagent_key);
214 }
215 fn locale_display(l: crate::localization::Locale) -> &'static str {
216 match l {
217 crate::localization::Locale::En => "en",
218 crate::localization::Locale::ZhHans => "zh-Hans",
219 crate::localization::Locale::ZhHant => "zh-Hant",
220 crate::localization::Locale::Ja => "ja",
221 crate::localization::Locale::PtBr => "pt-BR",
222 crate::localization::Locale::Es419 => "es-419",
223 crate::localization::Locale::Vi => "vi",
224 crate::localization::Locale::Ko => "ko",
225 crate::localization::Locale::Ca => "ca",
226 crate::localization::Locale::De => "de",
227 crate::localization::Locale::Fr => "fr",
228 crate::localization::Locale::Id => "id",
229 crate::localization::Locale::Hi => "hi",
230 crate::localization::Locale::Ru => "ru",
231 crate::localization::Locale::Uk => "uk",
232 }
233 }
234 fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str {
235 match d {
236 crate::tui::app::ComposerDensity::Compact => "compact",
237 crate::tui::app::ComposerDensity::Comfortable => "comfortable",
238 crate::tui::app::ComposerDensity::Spacious => "spacious",
239 }
240 }
241 fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str {
242 match s {
243 crate::tui::app::TranscriptSpacing::Compact => "compact",
244 crate::tui::app::TranscriptSpacing::Comfortable => "comfortable",
245 crate::tui::app::TranscriptSpacing::Spacious => "spacious",
246 }
247 }
248 let value = match key.as_str() {
249 "model" => {
250 if app.auto_model {
251 let mut label = "auto (auto-select model per turn)".to_string();
252 if let Some(effective) = app.last_effective_model.as_deref()
253 && effective != "auto"
254 {
255 label.push_str(&format!("; last: {effective}"));
256 }
257 Some(label)
258 } else {
259 Some(app.model.clone())
260 }
261 }
262 "provider" => Some(app.provider_identity_for_persistence().to_string()),
263 "approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()),
264 "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()),
265 "base_url" => {
266 let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
267 {
268 Ok(config) => config,
269 Err(err) => {
270 return CommandResult::error(format!("Failed to load config: {err}"));
271 }
272 };
273 Some(config.deepseek_base_url())
274 }
275 "provider_url" | "provider_base_url" | "endpoint" => {
276 let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
277 {
278 Ok(mut config) => {
279 config.provider = Some(app.provider_identity_for_persistence().to_string());
280 config
281 }
282 Err(err) => {
283 return CommandResult::error(format!("Failed to load config: {err}"));
284 }
285 };
286 Some(config.deepseek_base_url())
287 }
288 "context_window" | "context_window_tokens" => Some(format!(
289 "{} (effective {} from {})",
290 config_context_window_override(app)
291 .map_or_else(|| "not set".to_string(), |tokens| tokens.to_string()),
292 crate::route_budget::route_context_window_tokens(
293 app.api_provider,
294 app.effective_model_for_budget(),
295 app.active_route_limits,
296 ),
297 app.active_context_window_source.label(),
298 )),
299 "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()),
300 "locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
301 "theme" | "ui_theme" => {
302 Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
303 }
304 "background_color" | "background" | "bg" => {
305 crate::palette::hex_rgb_string(app.ui_theme.surface_bg)
306 .or_else(|| Some("(default)".to_string()))
307 }
308 "auto_compact" | "compact" => {
309 Some(if app.auto_compact { "true" } else { "false" }.to_string())
310 }
311 "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
312 "low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()),
313 "fancy_animations" | "fancy" | "animations" => Some(
314 if app.fancy_animations {
315 "true"
316 } else {
317 "false"
318 }
319 .to_string(),
320 ),
321 "bracketed_paste" | "paste" => Some(
322 if app.use_bracketed_paste {
323 "true"
324 } else {
325 "false"
326 }
327 .to_string(),
328 ),
329 "paste_burst_detection" | "paste_burst" => Some(
330 if app.use_paste_burst_detection {
331 "true"
332 } else {
333 "false"
334 }
335 .to_string(),
336 ),
337 "show_thinking" | "thinking" => {
338 Some(if app.show_thinking { "true" } else { "false" }.to_string())
339 }
340 "thinking_default_expanded" | "thinking_expanded" => Some(
341 if app.thinking_default_expanded {
342 "true"
343 } else {
344 "false"
345 }
346 .to_string(),
347 ),
348 "thinking_highlight" | "reasoning_highlight" => Some(
349 if app.thinking_highlight {
350 "true"
351 } else {
352 "false"
353 }
354 .to_string(),
355 ),
356 "show_tool_details" | "tool_details" => Some(
357 if app.show_tool_details {
358 "true"
359 } else {
360 "false"
361 }
362 .to_string(),
363 ),
364 "inline_diffs" | "inline_diff" | "diffs" => {
365 Some(app.inline_diff_mode.as_setting().to_string())
366 }
367 "mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
368 "max_history" | "history" => Some(app.max_input_history.to_string()),
369 "work_surface_placement" | "work_surface" | "work_rail" => {
370 Some(app.work_surface.placement.as_setting().to_string())
371 }
372 "rail_panel" | "rail" => Some(app.work_surface.panel.as_setting().to_string()),
373 "work_surface_top_height" | "work_top_height" => {
374 Some(app.work_surface.top_height.to_string())
375 }
376 "work_surface_side_width" | "work_side_width" => {
377 Some(app.work_surface.side_width.to_string())
378 }
379 "tool_collapse" | "tool_collapse_mode" | "collapse" => {
380 Some(app.tool_collapse_mode.as_setting().to_string())
381 }
382 "context_panel" | "context" | "session_panel" => {
383 Some(if app.context_panel { "true" } else { "false" }.to_string())
384 }
385 "sessions_rail" | "sessions_panel" | "session_rail" => {
386 Some(if app.sessions_rail { "true" } else { "false" }.to_string())
387 }
388 // Read the persisted value rather than reporting a hard-coded default:
389 // this setting is consumed at startup by `main`, so `App` has no live
390 // copy, and printing "false" unconditionally would misreport a user who
391 // has it on.
392 "session_auto_resume" | "auto_resume" => Some(
393 if crate::settings::Settings::load_persisted()
394 .map(|settings| settings.session_auto_resume)
395 .unwrap_or(false)
396 {
397 "true"
398 } else {
399 "false"
400 }
401 .to_string(),
402 ),
403 "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()),
404 "composer_border" | "border" => {
405 Some(if app.composer_border { "true" } else { "false" }.to_string())
406 }
407 "composer_vim_mode" | "vim_mode" | "vim" => Some(
408 if app.composer.vim_enabled {
409 "vim"
410 } else {
411 "normal"
412 }
413 .to_string(),
414 ),
415 "transcript_spacing" | "spacing" => {
416 Some(spacing_display(app.transcript_spacing).to_string())
417 }
418 "status_indicator" | "indicator" => Some(app.status_indicator.clone()),
419 "synchronized_output" | "sync_output" | "sync" => Some(
420 if app.synchronized_output_enabled {
421 "on"
422 } else {
423 "off"
424 }
425 .to_string(),
426 ),
427 "cost_currency" | "currency" => Some(
428 match app.cost_currency {
429 crate::pricing::CostCurrency::Usd => "usd",
430 crate::pricing::CostCurrency::Cny => "cny",
431 }
432 .to_string(),
433 ),
434 "default_model" => Settings::load().ok().map(|settings| {
435 settings
436 .default_model
437 .unwrap_or_else(|| "(default)".to_string())
438 }),
439 "reasoning_effort" | "effort" => Some(
440 app.reasoning_effort
441 .as_setting_for_provider(app.api_provider)
442 .to_string(),
443 ),
444 "workspace_follow_symlinks" | "follow_symlinks" => Settings::load().ok().map(|settings| {
445 format!(
446 "{} (restart required for engine tools)",
447 settings.workspace_follow_symlinks
448 )
449 }),
450 _ => {
451 let known = Settings::available_settings()
452 .iter()
453 .any(|(k, _)| k == &key);
454 if known {
455 Some("(see /settings for current value)".to_string())
456 } else {
457 None
458 }
459 }
460 };
461 match value {
462 Some(v) => CommandResult::message(format!("{key} = {v}")),
463 None => CommandResult::error(format!(
464 "Unknown setting '{key}'. See `/help config` for available settings."
465 )),
466 }
467 }
468
469 /// Open the typed settings editor. `text` preserves the legacy diagnostic
470 /// output for scripts and terminals that cannot render the modal.
471 pub fn settings_command(app: &mut App, arg: Option<&str>) -> CommandResult {
472 match arg.map(str::trim).filter(|value| !value.is_empty()) {
473 None => CommandResult::action(AppAction::OpenConfigView),
474 Some("text" | "show" | "diagnostic" | "diagnostics") => show_settings(app),
475 Some(_) => CommandResult::error("Usage: /settings [text]"),
476 }
477 }
478
479 /// Show persistent settings as plain text (legacy compatibility path).
480 pub fn show_settings(app: &mut App) -> CommandResult {
481 match Settings::load() {
482 Ok(settings) => CommandResult::message(settings.display(app.ui_locale)),
483 Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
484 }
485 }
486
487 /// Open the `/statusline` multi-select picker for configuring footer items.
488 pub fn status_line(_app: &mut App) -> CommandResult {
489 CommandResult::action(AppAction::OpenStatusPicker)
490 }
491
492 /// Toggle whether the live transcript renders full thinking detail.
493 pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult {
494 let next = match arg.map(str::trim).filter(|s| !s.is_empty()) {
495 None => !app.verbose_transcript,
496 Some(raw) => match raw.to_ascii_lowercase().as_str() {
497 "on" | "true" | "1" | "yes" => true,
498 "off" | "false" | "0" | "no" => false,
499 "toggle" => !app.verbose_transcript,
500 _ => {
501 return CommandResult::error(
502 "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.",
503 );
504 }
505 },
506 };
507
508 app.verbose_transcript = next;
509 app.mark_history_updated();
510 CommandResult::message(if next {
511 "Verbose transcript on: live thinking renders in full."
512 } else {
513 "Verbose transcript off: live thinking stays compact."
514 })
515 }
516
517 /// Place the work rail or pick its panel.
518 ///
519 /// `/rail top|left|right|off` sets placement; `/rail tasks|agents|context|
520 /// pinned` picks the panel. The two are orthogonal: where the rail sits and
521 /// what it shows. `/sidebar` remains registered as the alias users know.
522 /// Bare `/rail` reports the rail's *actual* rendered state — never a claim
523 /// about a surface that cannot render.
524 pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
525 const USAGE: &str = "Usage: /rail [top|left|right|off|tasks|agents|context|pinned] [--save]";
526 let raw = arg.map(str::trim).unwrap_or("");
527 let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
528 let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
529 if persist {
530 tokens.pop();
531 }
532
533 match tokens.as_slice() {
534 [] => return CommandResult::message(rail_status_message(app)),
535 [value] => {
536 let value = value.to_ascii_lowercase();
537 // Legacy focus words map onto the closest rail concept so muscle
538 // memory keeps working: "on" restores the default top rail,
539 // "off" hides it, panel names select panels.
540 let placement = match value.as_str() {
541 "top" | "on" | "show" | "visible" => {
542 Some(crate::tui::work_surface::WorkSurfacePlacement::Top)
543 }
544 "left" => Some(crate::tui::work_surface::WorkSurfacePlacement::Left),
545 "right" => Some(crate::tui::work_surface::WorkSurfacePlacement::Right),
546 "off" | "hide" | "hidden" | "closed" | "none" => {
547 Some(crate::tui::work_surface::WorkSurfacePlacement::Off)
548 }
549 _ => None,
550 };
551 let panel = match value.as_str() {
552 "tasks" | "activity" | "live" | "running" => {
553 Some(crate::tui::work_surface::RailPanel::Tasks)
554 }
555 "agents" | "subagents" | "sub-agents" => {
556 Some(crate::tui::work_surface::RailPanel::Agents)
557 }
558 "context" | "session" => Some(crate::tui::work_surface::RailPanel::Context),
559 "pinned" | "work" | "plan" | "todos" => {
560 Some(crate::tui::work_surface::RailPanel::Pinned)
561 }
562 _ => None,
563 };
564 match (placement, panel) {
565 (Some(placement), None) => {
566 app.work_surface.placement = placement;
567 app.work_surface.focused = false;
568 if persist {
569 let result = set_config_value(
570 app,
571 "work_surface_placement",
572 placement.as_setting(),
573 true,
574 );
575 if result.is_error {
576 return result;
577 }
578 }
579 }
580 (None, Some(panel)) => {
581 app.work_surface.panel = panel;
582 if persist {
583 let result = set_config_value(app, "rail_panel", panel.as_setting(), true);
584 if result.is_error {
585 return result;
586 }
587 }
588 }
589 _ => return CommandResult::error(USAGE),
590 }
591 }
592 _ => return CommandResult::error(USAGE),
593 }
594
595 app.needs_redraw = true;
596 CommandResult::message(rail_status_message(app))
597 }
598
599 /// Truthful rail readout: the placement and panel that actually render, with
600 /// the narrow-terminal fallback and an empty-Tasks collapse spelled out.
601 /// Never claims a panel is visible when no rail area was produced.
602 fn rail_status_message(app: &App) -> String {
603 use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement};
604
605 let placement = app.work_surface.placement;
606 if placement == WorkSurfacePlacement::Off {
607 return "Rail is off — no panel renders (/rail top|left|right to show it)".to_string();
608 }
609 let panel = app.work_surface.panel;
610 let mut message = format!(
611 "Rail: {} placement, {} panel",
612 placement.as_setting(),
613 panel.title()
614 );
615 let effective = app.work_surface.effective_placement();
616 if effective != placement && effective == WorkSurfacePlacement::Top {
617 message.push_str(" — side rails need a wider terminal, showing top for now");
618 }
619 if app.work_surface.last_area.is_none() {
620 if panel == RailPanel::Tasks {
621 message.push_str(" (currently hidden — no work to show)");
622 } else {
623 message.push_str(" (renders next frame)");
624 }
625 }
626 message
627 }
628
629 fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result<String, String> {
630 let trimmed = value.trim();
631 if trimmed.is_empty() {
632 return Err("provider_url cannot be empty".to_string());
633 }
634
635 if provider == ApiProvider::XiaomiMimo {
636 match trimmed.to_ascii_lowercase().as_str() {
637 "token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => {
638 return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string());
639 }
640 "payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => {
641 return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string());
642 }
643 _ => {}
644 }
645 }
646
647 if trimmed.contains("://") {
648 Ok(trimmed.to_string())
649 } else if provider == ApiProvider::XiaomiMimo {
650 Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string())
651 } else {
652 Err("provider_url must be a URL".to_string())
653 }
654 }
655
656 fn parse_config_bool(value: &str) -> Result<bool, String> {
657 match value.trim().to_ascii_lowercase().as_str() {
658 "on" | "true" | "yes" | "1" | "enabled" => Ok(true),
659 "off" | "false" | "no" | "0" | "disabled" => Ok(false),
660 _ => Err(format!(
661 "Failed to parse boolean '{value}': expected on/off, true/false, yes/no."
662 )),
663 }
664 }
665
666 fn approval_mode_config_value(mode: ApprovalMode) -> &'static str {
667 match mode {
668 ApprovalMode::Auto => "auto",
669 ApprovalMode::Bypass => "bypass",
670 ApprovalMode::Suggest => "on-request",
671 ApprovalMode::Never => "never",
672 }
673 }
674
675 fn is_ask_rules_config_token(token: &str) -> bool {
676 matches!(
677 token.to_ascii_lowercase().as_str(),
678 "ask-rules"
679 | "ask_rules"
680 | "askrules"
681 | "rules"
682 | "permission-rules"
683 | "permission_rules"
684 | "permissions"
685 )
686 }
687
688 fn config_editability_audit(app: &App) -> CommandResult {
689 let config = match load_command_config(app) {
690 Ok(config) => config,
691 Err(err) => return CommandResult::error(err),
692 };
693 let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
694 .map(|path| path.display().to_string())
695 .unwrap_or_else(|_| "(unresolved)".to_string());
696
697 let mut provider_config = config.clone();
698 provider_config.provider = Some(app.provider_identity_for_persistence().to_string());
699 let model = if app.auto_model {
700 "auto".to_string()
701 } else {
702 app.model.clone()
703 };
704 let saved_permission_posture = Settings::load()
705 .ok()
706 .and_then(|settings| settings.permission_posture)
707 .unwrap_or_else(|| "(unset)".to_string());
708 let configured_approval_policy = config
709 .approval_policy
710 .clone()
711 .unwrap_or_else(|| "(unset)".to_string());
712 let effective_permissions = if app.mode == AppMode::Plan {
713 "Read Only"
714 } else {
715 app.approval_mode.permission_chip_label()
716 };
717
718 let rows = [
719 (
720 "provider",
721 app.provider_identity_for_persistence().to_string(),
722 "session",
723 "/config provider <name>",
724 "Switches the active provider now; edit provider in config.toml for startup default.",
725 ),
726 (
727 "model",
728 model,
729 "session",
730 "/config model <id|auto>",
731 "Switches the active model now; use default_text_model in config.toml for startup default.",
732 ),
733 (
734 "effective_permissions",
735 effective_permissions.to_string(),
736 "runtime",
737 "Shift+Tab",
738 "Shows the effective Act permission posture; Plan remains Read Only.",
739 ),
740 (
741 "permission_posture",
742 saved_permission_posture,
743 "TUI settings",
744 "Shift+Tab",
745 "Saved in settings.toml and ignored when config/requirements manage approval policy.",
746 ),
747 (
748 "approval_policy",
749 configured_approval_policy,
750 "persisted config",
751 "/config approval_mode <auto|on-request|never> --save",
752 "Top-level managed policy; Full Access is not a valid value here.",
753 ),
754 (
755 "allow_shell",
756 app.allow_shell.to_string(),
757 "runtime+persisted",
758 "/config allow_shell <true|false> --save",
759 "Writes top-level allow_shell and applies to subsequent turns.",
760 ),
761 (
762 "stream_chunk_timeout_secs",
763 app.stream_chunk_timeout_secs.to_string(),
764 "runtime+persisted",
765 "/config stream_chunk_timeout_secs <0|1..3600> --save",
766 "Writes [tui].stream_chunk_timeout_secs and updates the running stream timeout.",
767 ),
768 (
769 "subagents.enabled",
770 subagents_config_display_value(&config, "enabled"),
771 "runtime+persisted",
772 "/config subagents on|off --save",
773 "Writes [subagents].enabled and updates subsequent sub-agent launches.",
774 ),
775 (
776 "subagents.max_concurrent",
777 subagents_config_display_value(&config, "max_concurrent"),
778 "runtime+persisted",
779 "/config subagents max_concurrent <n> --save",
780 "Clamped with Config::max_subagents and written to [subagents].max_concurrent.",
781 ),
782 (
783 "subagents.max_depth",
784 subagents_config_display_value(&config, "max_depth"),
785 "runtime+persisted",
786 "/config subagents max_depth <n> --save",
787 "Clamped to the configured spawn-depth ceiling.",
788 ),
789 (
790 "subagents.launch_concurrency",
791 subagents_config_display_value(&config, "launch_concurrency"),
792 "runtime+persisted",
793 "/config subagents launch_concurrency <n> --save",
794 "Clamped to the resolved sub-agent concurrency cap.",
795 ),
796 (
797 "subagents.api_timeout_secs",
798 subagents_config_display_value(&config, "api_timeout_secs"),
799 "runtime+persisted",
800 "/config subagents api_timeout_secs <seconds> --save",
801 "0 means the compiled default; non-zero values are clamped to the documented range.",
802 ),
803 (
804 "subagents.heartbeat_timeout_secs",
805 subagents_config_display_value(&config, "heartbeat_timeout_secs"),
806 "runtime+persisted",
807 "/config subagents heartbeat_timeout_secs <seconds> --save",
808 "0 means the compiled default; non-zero values are clamped to the documented range.",
809 ),
810 (
811 "base_url",
812 config.deepseek_base_url(),
813 "persisted restart",
814 "/config base_url <url> --save",
815 "Writes top-level base_url; model clients read it on startup.",
816 ),
817 (
818 "providers.<active>.base_url",
819 provider_config.deepseek_base_url(),
820 "persisted restart",
821 "/config provider_url <url> --save",
822 "Writes the active provider table; model clients read it on startup.",
823 ),
824 (
825 "providers.<active>.context_window",
826 config_context_window_override(app)
827 .map_or_else(|| "(unset)".to_string(), |tokens| tokens.to_string()),
828 "persisted restart",
829 "edit [providers.<active>] context_window = <tokens>",
830 "Overrides compaction, context-pressure, header, and preflight input budgets; use 262144 to cap a 1M route to 256K.",
831 ),
832 (
833 "effective_context_window",
834 format!(
835 "{} ({})",
836 crate::route_budget::route_context_window_tokens(
837 app.api_provider,
838 app.effective_model_for_budget(),
839 app.active_route_limits,
840 ),
841 app.active_context_window_source.label(),
842 ),
843 "runtime",
844 "/config context_window",
845 "The shared resolved window used by every active-route budget surface.",
846 ),
847 (
848 "mcp_config_path",
849 app.mcp_config_path.display().to_string(),
850 "persisted live reload",
851 "/config mcp_config_path <path> --save",
852 "Run /mcp reload to rebuild the live model-visible tool pool.",
853 ),
854 (
855 "workspace_follow_symlinks",
856 app.workspace_follow_symlinks.to_string(),
857 "partial restart",
858 "/config workspace_follow_symlinks <true|false> --save",
859 "Updates TUI file completion now; engine tools require restart.",
860 ),
861 (
862 "instructions",
863 file_only_status(config.instructions.as_ref().map(|v| !v.is_empty())),
864 "file-only restart",
865 "edit config.toml",
866 "Prompt layers are loaded before the first turn.",
867 ),
868 (
869 "hooks",
870 file_only_status(config.hooks.as_ref().map(|_| true)),
871 "file-only",
872 "edit config.toml",
873 "Hook definitions are structured TOML, not a scalar runtime setting.",
874 ),
875 (
876 "network",
877 file_only_status(config.network.as_ref().map(|_| true)),
878 "file-only",
879 "edit config.toml",
880 "Network policy is evaluated by tool dispatch and should be reviewed as TOML.",
881 ),
882 (
883 "tools",
884 file_only_status(config.tools.as_ref().map(|_| true)),
885 "file-only restart",
886 "edit config.toml",
887 "Tool catalog policy is built before model/tool negotiation.",
888 ),
889 (
890 "memory",
891 file_only_status(config.memory.as_ref().map(|_| true)),
892 "file-only restart",
893 "edit config.toml",
894 "Memory loading changes prompt context and is resolved at startup.",
895 ),
896 (
897 "runtime_api",
898 file_only_status(config.runtime_api.as_ref().map(|_| true)),
899 "file-only restart",
900 "edit config.toml",
901 "Serve/API tuning belongs to the runtime server startup path.",
902 ),
903 (
904 "vision_model",
905 file_only_status(config.vision_model.as_ref().map(|_| true)),
906 "file-only restart",
907 "edit config.toml",
908 "Image-analysis provider clients are configured outside the scalar /config editor.",
909 ),
910 ];
911
912 let mut lines = Vec::new();
913 lines.push("Config editability audit".to_string());
914 lines.push(format!("Config path: {config_path}"));
915 lines.push("Key | Current | Editability | Command / reason".to_string());
916 for (key, current, editability, command, note) in rows {
917 lines.push(format!("{key} | {current} | {editability} | {command}"));
918 lines.push(format!(" {note}"));
919 }
920 CommandResult::message(lines.join("\n"))
921 }
922
923 fn file_only_status(configured: Option<bool>) -> String {
924 match configured {
925 Some(true) => "configured".to_string(),
926 Some(false) => "empty".to_string(),
927 None => "unset".to_string(),
928 }
929 }
930
931 fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
932 if raw == 0 {
933 format!("0 (default {resolved})")
934 } else {
935 resolved.to_string()
936 }
937 }
938
939 fn subagents_config_command(app: &mut App, raw: &str) -> CommandResult {
940 let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
941 let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
942 if persist {
943 tokens.pop();
944 }
945
946 match tokens.as_slice() {
947 [] | ["status"] => subagents_status(app),
948 ["on"] | ["enable"] | ["enabled"] => {
949 set_subagents_config_value(app, "enabled", "true", persist)
950 }
951 ["off"] | ["disable"] | ["disabled"] => {
952 set_subagents_config_value(app, "enabled", "false", persist)
953 }
954 [key] => show_subagents_setting(app, key),
955 [key, value] => set_subagents_config_value(app, key, value, persist),
956 _ => CommandResult::error(
957 "Usage: /config subagents [status|on|off|enabled|max_concurrent|max_depth|launch_concurrency|api_timeout_secs|heartbeat_timeout_secs <value>] [--save]",
958 ),
959 }
960 }
961
962 fn load_command_config(app: &App) -> Result<Config, String> {
963 Config::load(app.config_path.clone(), app.config_profile.as_deref())
964 .map_err(|err| format!("Failed to load config: {err}"))
965 }
966
967 fn subagents_status(app: &App) -> CommandResult {
968 let config = match load_command_config(app) {
969 Ok(config) => config,
970 Err(err) => return CommandResult::error(err),
971 };
972 let path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
973 .map(|path| path.display().to_string())
974 .unwrap_or_else(|_| "(unresolved)".to_string());
975 let disabled_reason = config.subagents_disabled_reason();
976 let active_provider = app.api_provider;
977 let subagents = config.subagents.as_ref();
978 let provider_subagents = config.subagent_provider_config(active_provider);
979 let explicit_enabled = subagents.and_then(|cfg| cfg.enabled);
980 let raw_max_concurrent = subagents.and_then(|cfg| cfg.max_concurrent);
981 let raw_max_depth = subagents.and_then(|cfg| cfg.max_depth);
982 let raw_launch = subagents.and_then(|cfg| cfg.launch_concurrency);
983 let raw_api = subagents.and_then(|cfg| cfg.api_timeout_secs);
984 let raw_heartbeat = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
985 let mut lines = Vec::new();
986 lines.push(format!(
987 "Sub-agents: {}",
988 disabled_reason
989 .map(|reason| format!("disabled ({reason})"))
990 .unwrap_or_else(|| "enabled".to_string())
991 ));
992 lines.push(format!("Config path: {path}"));
993 lines.push(format!(
994 "Active provider: {} ({})",
995 active_provider.as_str(),
996 active_provider.display_name()
997 ));
998 lines.push(format!(
999 "subagents.enabled = {}",
1000 explicit_enabled
1001 .map(|value| value.to_string())
1002 .unwrap_or_else(|| "default true".to_string())
1003 ));
1004 lines.push(format!(
1005 "subagents.max_concurrent = {} (resolved global {}; active provider {})",
1006 option_display(raw_max_concurrent),
1007 config.max_subagents(),
1008 config.max_subagents_for_provider(active_provider)
1009 ));
1010 lines.push(format!(
1011 "subagents.max_depth = {} (resolved global {}; active provider {})",
1012 option_display(raw_max_depth),
1013 config.subagent_max_spawn_depth(),
1014 config.subagent_max_spawn_depth_for_provider(active_provider)
1015 ));
1016 lines.push(format!(
1017 "subagents.launch_concurrency = {} (resolved global {}; active provider {})",
1018 option_display(raw_launch),
1019 config.launch_concurrency(),
1020 config.launch_concurrency_for_provider(active_provider)
1021 ));
1022 lines.push(format!(
1023 "subagents.api_timeout_secs = {} (resolved global {}; active provider {})",
1024 option_display(raw_api),
1025 config.subagent_api_timeout_secs(),
1026 config.subagent_api_timeout_secs_for_provider(active_provider)
1027 ));
1028 lines.push(format!(
1029 "subagents.heartbeat_timeout_secs = {} (resolved global {}; active provider {})",
1030 option_display(raw_heartbeat),
1031 config.subagent_heartbeat_timeout_secs(),
1032 config.subagent_heartbeat_timeout_secs_for_provider(active_provider)
1033 ));
1034 if let Some(provider_subagents) = provider_subagents {
1035 lines.push(format!(
1036 "subagents.providers.{}.enabled = {}",
1037 active_provider.as_str(),
1038 provider_subagents
1039 .enabled
1040 .map(|value| value.to_string())
1041 .unwrap_or_else(|| "inherits".to_string())
1042 ));
1043 lines.push(format!(
1044 "subagents.providers.{}.max_concurrent = {}",
1045 active_provider.as_str(),
1046 option_display(provider_subagents.max_concurrent)
1047 ));
1048 lines.push(format!(
1049 "subagents.providers.{}.max_depth = {}",
1050 active_provider.as_str(),
1051 option_display(provider_subagents.max_depth)
1052 ));
1053 lines.push(format!(
1054 "subagents.providers.{}.launch_concurrency = {}",
1055 active_provider.as_str(),
1056 option_display(provider_subagents.launch_concurrency)
1057 ));
1058 lines.push(format!(
1059 "subagents.providers.{}.max_admitted = {}",
1060 active_provider.as_str(),
1061 option_display(provider_subagents.max_admitted)
1062 ));
1063 } else {
1064 lines.push(format!(
1065 "subagents.providers.{} = inherits global",
1066 active_provider.as_str()
1067 ));
1068 }
1069 CommandResult::message(lines.join("\n"))
1070 }
1071
1072 fn show_subagents_setting(app: &App, key: &str) -> CommandResult {
1073 let config = match load_command_config(app) {
1074 Ok(config) => config,
1075 Err(err) => return CommandResult::error(err),
1076 };
1077 let Some(key) = canonical_subagents_key(key) else {
1078 return CommandResult::error(format!(
1079 "Unknown subagents setting '{key}'. Use `/config subagents status`."
1080 ));
1081 };
1082 let active_provider = app.api_provider;
1083 let subagents = config.subagents.as_ref();
1084 let value = match key {
1085 "enabled" => subagents
1086 .and_then(|cfg| cfg.enabled)
1087 .map(|value| value.to_string())
1088 .unwrap_or_else(|| "default true".to_string()),
1089 "max_concurrent" => format!(
1090 "{} (resolved global {}; active provider {})",
1091 option_display(subagents.and_then(|cfg| cfg.max_concurrent)),
1092 config.max_subagents(),
1093 config.max_subagents_for_provider(active_provider)
1094 ),
1095 "max_depth" => format!(
1096 "{} (resolved global {}; active provider {})",
1097 option_display(subagents.and_then(|cfg| cfg.max_depth)),
1098 config.subagent_max_spawn_depth(),
1099 config.subagent_max_spawn_depth_for_provider(active_provider)
1100 ),
1101 "launch_concurrency" => format!(
1102 "{} (resolved global {}; active provider {})",
1103 option_display(subagents.and_then(|cfg| cfg.launch_concurrency)),
1104 config.launch_concurrency(),
1105 config.launch_concurrency_for_provider(active_provider)
1106 ),
1107 "api_timeout_secs" => format!(
1108 "{} (resolved global {}; active provider {})",
1109 option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)),
1110 config.subagent_api_timeout_secs(),
1111 config.subagent_api_timeout_secs_for_provider(active_provider)
1112 ),
1113 "heartbeat_timeout_secs" => format!(
1114 "{} (resolved global {}; active provider {})",
1115 option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)),
1116 config.subagent_heartbeat_timeout_secs(),
1117 config.subagent_heartbeat_timeout_secs_for_provider(active_provider)
1118 ),
1119 _ => unreachable!("canonical subagent key"),
1120 };
1121 CommandResult::message(format!("subagents.{key} = {value}"))
1122 }
1123
1124 fn option_display<T: std::fmt::Display>(value: Option<T>) -> String {
1125 value
1126 .map(|value| value.to_string())
1127 .unwrap_or_else(|| "default".to_string())
1128 }
1129
1130 fn canonical_subagents_key(key: &str) -> Option<&'static str> {
1131 let normalized = key.trim().to_ascii_lowercase();
1132 let key = normalized
1133 .strip_prefix("subagents.")
1134 .unwrap_or(normalized.as_str());
1135 match key {
1136 "enabled" | "enable" => Some("enabled"),
1137 "max_concurrent" | "max_subagents" | "concurrency" | "cap" => Some("max_concurrent"),
1138 "max_depth" | "depth" | "spawn_depth" => Some("max_depth"),
1139 "launch_concurrency" | "launches" | "launch" => Some("launch_concurrency"),
1140 "api_timeout_secs" | "api_timeout" | "step_timeout_secs" => Some("api_timeout_secs"),
1141 "heartbeat_timeout_secs" | "heartbeat_timeout" | "heartbeat" => {
1142 Some("heartbeat_timeout_secs")
1143 }
1144 _ => None,
1145 }
1146 }
1147
1148 fn set_subagents_config_value(
1149 app: &mut App,
1150 key: &str,
1151 value: &str,
1152 persist: bool,
1153 ) -> CommandResult {
1154 let Some(key) = canonical_subagents_key(key) else {
1155 return CommandResult::error(format!(
1156 "Unknown subagents setting '{key}'. Use `/config subagents status`."
1157 ));
1158 };
1159 let mut config = match load_command_config(app) {
1160 Ok(config) => config,
1161 Err(err) => return CommandResult::error(err),
1162 };
1163 let current_max_subagents = config.max_subagents() as u64;
1164 let subagents = config
1165 .subagents
1166 .get_or_insert_with(SubagentsConfig::default);
1167
1168 let mut note = None;
1169 let save_result = match key {
1170 "enabled" => {
1171 let enabled = match parse_config_bool(value) {
1172 Ok(enabled) => enabled,
1173 Err(err) => return CommandResult::error(err),
1174 };
1175 subagents.enabled = Some(enabled);
1176 if persist {
1177 Some(persist_subagents_bool_key(
1178 app.config_path.as_deref(),
1179 "enabled",
1180 enabled,
1181 ))
1182 } else {
1183 None
1184 }
1185 }
1186 "max_concurrent" => {
1187 let raw = match parse_subagents_u64(key, value) {
1188 Ok(raw) => raw,
1189 Err(err) => return CommandResult::error(err),
1190 };
1191 let clamped = raw.min(MAX_SUBAGENTS as u64);
1192 if clamped != raw {
1193 note = Some(format!("clamped from {raw} to {clamped}"));
1194 }
1195 subagents.max_concurrent = Some(clamped as usize);
1196 if persist {
1197 Some(persist_subagents_integer_key(
1198 app.config_path.as_deref(),
1199 "max_concurrent",
1200 clamped,
1201 ))
1202 } else {
1203 None
1204 }
1205 }
1206 "max_depth" => {
1207 let raw = match parse_subagents_u64(key, value) {
1208 Ok(raw) => raw,
1209 Err(err) => return CommandResult::error(err),
1210 };
1211 let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
1212 let clamped = raw.min(ceiling);
1213 if clamped != raw {
1214 note = Some(format!("clamped from {raw} to {clamped}"));
1215 }
1216 subagents.max_depth = Some(clamped as u32);
1217 if persist {
1218 Some(persist_subagents_integer_key(
1219 app.config_path.as_deref(),
1220 "max_depth",
1221 clamped,
1222 ))
1223 } else {
1224 None
1225 }
1226 }
1227 "launch_concurrency" => {
1228 let raw = match parse_subagents_u64(key, value) {
1229 Ok(raw) => raw,
1230 Err(err) => return CommandResult::error(err),
1231 };
1232 let clamped = raw.clamp(1, current_max_subagents);
1233 if clamped != raw {
1234 note = Some(format!("clamped from {raw} to {clamped}"));
1235 }
1236 subagents.launch_concurrency = Some(clamped as usize);
1237 if persist {
1238 Some(persist_subagents_integer_key(
1239 app.config_path.as_deref(),
1240 "launch_concurrency",
1241 clamped,
1242 ))
1243 } else {
1244 None
1245 }
1246 }
1247 "api_timeout_secs" => {
1248 let raw = match parse_subagents_u64(key, value) {
1249 Ok(raw) => raw,
1250 Err(err) => return CommandResult::error(err),
1251 };
1252 let stored = if raw == 0 {
1253 0
1254 } else {
1255 raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)
1256 };
1257 if stored != raw {
1258 note = Some(format!("clamped from {raw} to {stored}"));
1259 }
1260 subagents.api_timeout_secs = Some(stored);
1261 if persist {
1262 Some(persist_subagents_integer_key(
1263 app.config_path.as_deref(),
1264 "api_timeout_secs",
1265 stored,
1266 ))
1267 } else {
1268 None
1269 }
1270 }
1271 "heartbeat_timeout_secs" => {
1272 let raw = match parse_subagents_u64(key, value) {
1273 Ok(raw) => raw,
1274 Err(err) => return CommandResult::error(err),
1275 };
1276 let stored = if raw == 0 {
1277 0
1278 } else {
1279 raw.clamp(
1280 MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
1281 MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
1282 )
1283 };
1284 if stored != raw {
1285 note = Some(format!("clamped from {raw} to {stored}"));
1286 }
1287 subagents.heartbeat_timeout_secs = Some(stored);
1288 if persist {
1289 Some(persist_subagents_integer_key(
1290 app.config_path.as_deref(),
1291 "heartbeat_timeout_secs",
1292 stored,
1293 ))
1294 } else {
1295 None
1296 }
1297 }
1298 _ => unreachable!("canonical subagent key"),
1299 };
1300
1301 let save_suffix = if let Some(result) = save_result {
1302 match result {
1303 Ok(path) => format!("saved to {}", path.display()),
1304 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1305 }
1306 } else {
1307 "session only, add --save to persist".to_string()
1308 };
1309
1310 if key == "max_concurrent" {
1311 app.max_subagents = config.max_subagents_for_provider(app.api_provider);
1312 }
1313 let display_value = subagents_config_display_value(&config, key);
1314 let note = note.map(|note| format!("; {note}")).unwrap_or_default();
1315 CommandResult::with_message_and_action(
1316 format!(
1317 "subagents.{key} = {display_value} ({save_suffix}; runtime updated for subsequent turns{note})"
1318 ),
1319 subagents_runtime_action(app, &config),
1320 )
1321 }
1322
1323 fn parse_subagents_u64(key: &str, value: &str) -> Result<u64, String> {
1324 value
1325 .trim()
1326 .parse::<u64>()
1327 .map_err(|_| format!("subagents.{key} must be a whole number"))
1328 }
1329
1330 fn subagents_config_display_value(config: &Config, key: &str) -> String {
1331 let subagents = config.subagents.as_ref();
1332 match key {
1333 "enabled" => subagents
1334 .and_then(|cfg| cfg.enabled)
1335 .map(|value| value.to_string())
1336 .unwrap_or_else(|| "default true".to_string()),
1337 "max_concurrent" => {
1338 if subagents.and_then(|cfg| cfg.max_concurrent) == Some(0) {
1339 "0 (disabled)".to_string()
1340 } else {
1341 config.max_subagents().to_string()
1342 }
1343 }
1344 "max_depth" => {
1345 if subagents.and_then(|cfg| cfg.max_depth) == Some(0) {
1346 "0 (agent tool disabled)".to_string()
1347 } else {
1348 config.subagent_max_spawn_depth().to_string()
1349 }
1350 }
1351 "launch_concurrency" => config.launch_concurrency().to_string(),
1352 "api_timeout_secs" => {
1353 let raw = subagents.and_then(|cfg| cfg.api_timeout_secs);
1354 if raw == Some(0) {
1355 format!("0 (default {DEFAULT_SUBAGENT_API_TIMEOUT_SECS})")
1356 } else {
1357 config.subagent_api_timeout_secs().to_string()
1358 }
1359 }
1360 "heartbeat_timeout_secs" => {
1361 let raw = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
1362 if raw == Some(0) {
1363 format!("0 (default {DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS})")
1364 } else {
1365 config.subagent_heartbeat_timeout_secs().to_string()
1366 }
1367 }
1368 _ => unreachable!("canonical subagent key"),
1369 }
1370 }
1371
1372 fn subagents_runtime_action(app: &App, config: &Config) -> AppAction {
1373 let provider = app.api_provider;
1374 let max_subagents = config
1375 .max_subagents_for_provider(provider)
1376 .clamp(1, MAX_SUBAGENTS);
1377 AppAction::UpdateSubagentRuntimeConfig {
1378 enabled: config.subagents_enabled_for_provider(provider),
1379 max_subagents,
1380 launch_concurrency: config.launch_concurrency_for_provider(provider),
1381 max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
1382 api_timeout_secs: config.subagent_api_timeout_secs_for_provider(provider),
1383 heartbeat_timeout_secs: config.subagent_heartbeat_timeout_secs_for_provider(provider),
1384 }
1385 }
1386
1387 /// The subject a live-route key belongs to, or `None` if the key does not touch
1388 /// the route the engine is currently acting on.
1389 ///
1390 /// This is the single list the #2982 turn lock is enforced from. It exists
1391 /// because the lock used to live in the *selectors* — the Tab cycle, the
1392 /// pickers, the hotbar — while `/set <key> <value>` and `/config <key> <value>`
1393 /// reached the same live state through a different door. A slash command is
1394 /// reachable mid-turn (the composer accepts Shift+Enter and the slash menu while
1395 /// `is_loading`), so during a running turn `/set model …` could swap the route
1396 /// out from under the engine and persist it.
1397 ///
1398 /// `default_mode` is deliberately absent: it is a restart default that
1399 /// `set_config_value` explicitly does *not* apply to the live session, so
1400 /// refusing it would lock a key that cannot affect the turn.
1401 fn live_route_setting_subject(key: &str) -> Option<MessageId> {
1402 match key {
1403 "mode" => Some(MessageId::SettingSubjectMode),
1404 // `default_model` is not merely a startup default: for the DeepSeek
1405 // routes `set_config_value` installs it as the live model.
1406 "model" | "default_model" => Some(MessageId::SettingSubjectModel),
1407 "reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking),
1408 "provider" => Some(MessageId::SettingSubjectProvider),
1409 "approval_mode" | "approval_policy" | "approval" => {
1410 Some(MessageId::SettingSubjectPermissions)
1411 }
1412 _ => None,
1413 }
1414 }
1415
1416 /// Modify a setting at runtime
1417 pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
1418 let key = key.to_lowercase();
1419 if let Some(subagent_key) = key.strip_prefix("subagents.") {
1420 return set_subagents_config_value(app, subagent_key, value, persist);
1421 }
1422
1423 // Refuse before *anything* — before the disk write, and before the live
1424 // `App` mutation each arm performs. Placing the check at the top is what
1425 // makes it central: every caller of this function (`/set`, `/config k v`,
1426 // the preset mirror, the schema-driven config editor, the runtime
1427 // `ConfigUpdated` event) inherits it, and none of them can half-apply.
1428 if let Some(subject) = live_route_setting_subject(key.as_str())
1429 && app.is_loading
1430 {
1431 return CommandResult::error(app.setting_locked_message(subject));
1432 }
1433
1434 match key.as_str() {
1435 "model" => {
1436 // Support "/model auto" — auto-select model based on request complexity
1437 if value.trim().eq_ignore_ascii_case("auto") {
1438 app.set_model_selection("auto".to_string());
1439 app.update_model_compaction_budget();
1440 app.session.last_prompt_tokens = None;
1441 app.session.last_completion_tokens = None;
1442 app.session.last_output_throughput = None;
1443 return CommandResult::with_message_and_action(
1444 format!(
1445 "model = auto (auto-select model per turn; thinking = {})",
1446 app.reasoning_effort_display_label()
1447 ),
1448 AppAction::UpdateCompaction(app.compaction_config()),
1449 );
1450 }
1451 // Route-aware: a custom DeepSeek (or other) endpoint owns its model
1452 // namespace. Provider-only normalization would reject a non-DeepSeek
1453 // id that the live session is already allowed to use via `/model`.
1454 // OpenCode Go stays protocol-strict even on a custom host.
1455 let model = if app.api_provider == ApiProvider::OpencodeGo {
1456 let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else {
1457 return CommandResult::error(format!(
1458 "Invalid model '{value}' for provider {}.",
1459 app.api_provider.as_str()
1460 ));
1461 };
1462 if let Err(reason) = validate_route(app.api_provider, &model) {
1463 return CommandResult::error(reason);
1464 }
1465 model
1466 } else if app.accepts_custom_model_ids() {
1467 let Some(model) = normalize_custom_model_id(value) else {
1468 return CommandResult::error(format!(
1469 "Invalid model '{value}' for provider {}.",
1470 app.api_provider.as_str()
1471 ));
1472 };
1473 model
1474 } else {
1475 let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else {
1476 return CommandResult::error(format!(
1477 "Invalid model '{value}' for provider {}.",
1478 app.api_provider.as_str()
1479 ));
1480 };
1481 if let Err(reason) = validate_route(app.api_provider, &model) {
1482 return CommandResult::error(reason);
1483 }
1484 model
1485 };
1486 app.set_model_selection(model.clone());
1487 app.update_model_compaction_budget();
1488 app.session.last_prompt_tokens = None;
1489 app.session.last_completion_tokens = None;
1490 app.session.last_output_throughput = None;
1491 return CommandResult::with_message_and_action(
1492 format!("model = {model}"),
1493 AppAction::UpdateCompaction(app.compaction_config()),
1494 );
1495 }
1496 "provider" => {
1497 let value = value.trim();
1498 let Some(provider) = ApiProvider::parse(value) else {
1499 return CommandResult::error(format!(
1500 "Unknown provider '{value}'. Use: {}.",
1501 ApiProvider::names_hint()
1502 ));
1503 };
1504 if provider == app.api_provider {
1505 return CommandResult::message(format!("provider = {}", provider.as_str()));
1506 }
1507 return CommandResult::with_message_and_action(
1508 format!("provider = {}", provider.as_str()),
1509 AppAction::SwitchProvider {
1510 provider,
1511 model: None,
1512 },
1513 );
1514 }
1515 "approval_mode" | "approval_policy" | "approval" => {
1516 let use_tui_default = matches!(
1517 value
1518 .trim()
1519 .to_ascii_lowercase()
1520 .replace([' ', '_'], "-")
1521 .as_str(),
1522 "default" | "tui-default" | "use-tui-default"
1523 );
1524 if use_tui_default {
1525 if !persist {
1526 return CommandResult::error(
1527 "Removing the config approval override requires --save.",
1528 );
1529 }
1530 let control = match load_command_config(app) {
1531 Ok(config) => config.approval_policy_control(
1532 app.config_path.as_deref(),
1533 app.config_profile.as_deref(),
1534 &app.workspace,
1535 ),
1536 Err(err) => return CommandResult::error(err),
1537 };
1538 if !matches!(
1539 control,
1540 crate::config::ApprovalPolicyControl::RootConfig
1541 | crate::config::ApprovalPolicyControl::Unset
1542 ) {
1543 return CommandResult::error(format!(
1544 "Approval posture is controlled by {}; change that source first.",
1545 control.label()
1546 ));
1547 }
1548 return match persist_unset_root_key(app.config_path.as_deref(), "approval_policy") {
1549 Ok(path) => {
1550 let saved_mode = Settings::load_persisted()
1551 .ok()
1552 .and_then(|settings| settings.permission_posture)
1553 .as_deref()
1554 .and_then(ApprovalMode::from_config_value)
1555 .unwrap_or(ApprovalMode::Suggest);
1556 app.set_agent_approval_posture(saved_mode);
1557 app.clear_saved_approval_policy_lock();
1558 CommandResult::with_message_and_action(
1559 format!(
1560 "approval_policy removed from {}; new sessions use the TUI {} default",
1561 path.display(),
1562 saved_mode.permission_chip_label()
1563 ),
1564 AppAction::ApprovalPolicyPersisted { policy: None },
1565 )
1566 }
1567 Err(err) => CommandResult::error(format!("Failed to save: {err}")),
1568 };
1569 }
1570 let control = match load_command_config(app) {
1571 Ok(config) => config.approval_policy_control(
1572 app.config_path.as_deref(),
1573 app.config_profile.as_deref(),
1574 &app.workspace,
1575 ),
1576 Err(err) => return CommandResult::error(err),
1577 };
1578 let control_allows_change = if persist {
1579 control.editable_root()
1580 } else {
1581 matches!(control, crate::config::ApprovalPolicyControl::Unset)
1582 };
1583 if !control_allows_change {
1584 return CommandResult::error(format!(
1585 "Approval posture is controlled by {}; {}.",
1586 control.label(),
1587 if matches!(control, crate::config::ApprovalPolicyControl::RootConfig) {
1588 "save a new config value or choose Use TUI permission default"
1589 } else {
1590 "change that source first"
1591 }
1592 ));
1593 }
1594 let mode = ApprovalMode::from_config_value(value);
1595 return match mode {
1596 Some(ApprovalMode::Bypass)
1597 if persist
1598 && matches!(control, crate::config::ApprovalPolicyControl::RootConfig) =>
1599 {
1600 match app.adopt_root_approval_posture(ApprovalMode::Bypass) {
1601 Ok(()) => CommandResult::with_message_and_action(
1602 "approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)",
1603 AppAction::ApprovalPolicyPersisted { policy: None },
1604 ),
1605 Err(reason) => {
1606 CommandResult::error(format!("Failed to save Full Access: {reason}"))
1607 }
1608 }
1609 }
1610 Some(ApprovalMode::Bypass) if persist => CommandResult::error(
1611 "Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.",
1612 ),
1613 Some(m) => {
1614 if persist {
1615 let saved = approval_mode_config_value(m);
1616 match persist_root_string_key(
1617 app.config_path.as_deref(),
1618 "approval_policy",
1619 saved,
1620 ) {
1621 Ok(path) => {
1622 app.set_agent_approval_posture(m);
1623 app.mark_approval_policy_locked();
1624 CommandResult::with_message_and_action(
1625 format!(
1626 "approval_mode = {} (saved to {} as approval_policy = \"{}\")",
1627 m.permission_chip_label(),
1628 path.display(),
1629 saved
1630 ),
1631 AppAction::ApprovalPolicyPersisted {
1632 policy: Some(saved.to_string()),
1633 },
1634 )
1635 }
1636 Err(err) => CommandResult::error(format!("Failed to save: {err}")),
1637 }
1638 } else {
1639 app.set_agent_approval_posture(m);
1640 CommandResult::with_message_and_action(
1641 format!(
1642 "approval_mode = {} (session only, add --save to persist)",
1643 m.permission_chip_label()
1644 ),
1645 AppAction::ModeChanged(app.mode),
1646 )
1647 }
1648 }
1649 None => CommandResult::error(
1650 "Invalid approval_mode. Use: auto-review/auto, ask/suggest/on-request, full-access, never/deny",
1651 ),
1652 };
1653 }
1654 "allow_shell" | "shell" | "exec_shell" => {
1655 let control = match load_command_config(app) {
1656 Ok(config) => config.allow_shell_control(
1657 app.config_path.as_deref(),
1658 app.config_profile.as_deref(),
1659 &app.workspace,
1660 ),
1661 Err(err) => return CommandResult::error(err),
1662 };
1663 if !control.editable_root() {
1664 return CommandResult::error(format!(
1665 "Shell access is controlled by {}; change that source first.",
1666 control.label()
1667 ));
1668 }
1669 let enabled = match parse_config_bool(value) {
1670 Ok(enabled) => enabled,
1671 Err(err) => return CommandResult::error(err),
1672 };
1673 let suffix = if persist {
1674 match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) {
1675 Ok(path) => format!(" (saved to {})", path.display()),
1676 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1677 }
1678 } else {
1679 " (session only, add --save to persist)".to_string()
1680 };
1681 app.set_agent_shell_access(enabled);
1682 let mode_hint = if enabled {
1683 " Act mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves."
1684 } else {
1685 " Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`."
1686 };
1687 return CommandResult::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}"));
1688 }
1689 "mcp_config_path" | "mcp" => {
1690 if value.trim().is_empty() {
1691 return CommandResult::error("mcp_config_path cannot be empty");
1692 }
1693 let next_path = PathBuf::from(expand_tilde(value));
1694 let path_changed = next_path != app.mcp_config_path;
1695 app.mcp_config_path = next_path;
1696 if path_changed {
1697 app.mcp_reload_required = true;
1698 }
1699 let reload_note = if path_changed {
1700 "; run /mcp reload to rebuild the live tool pool"
1701 } else {
1702 ""
1703 };
1704 let message = if persist {
1705 match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value)
1706 {
1707 Ok(path) => format!(
1708 "mcp_config_path = {} (saved to {}){}",
1709 app.mcp_config_path.display(),
1710 path.display(),
1711 reload_note
1712 ),
1713 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1714 }
1715 } else {
1716 format!(
1717 "mcp_config_path = {} (session only){}",
1718 app.mcp_config_path.display(),
1719 reload_note
1720 )
1721 };
1722 return CommandResult::message(message);
1723 }
1724 "base_url" => {
1725 let value = value.trim();
1726 if value.is_empty() {
1727 return CommandResult::error("base_url cannot be empty");
1728 }
1729 if persist {
1730 match persist_root_string_key(app.config_path.as_deref(), "base_url", value) {
1731 Ok(path) => {
1732 return CommandResult::message(format!(
1733 "base_url = {value} (saved to {})",
1734 path.display()
1735 ));
1736 }
1737 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1738 }
1739 }
1740 return CommandResult::error(
1741 "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
1742 );
1743 }
1744 "provider_url" | "provider_base_url" | "endpoint" => {
1745 let value = match resolve_provider_url_value(app.api_provider, value) {
1746 Ok(value) => value,
1747 Err(err) => return CommandResult::error(err),
1748 };
1749 if matches!(
1750 app.api_provider,
1751 ApiProvider::Deepseek | ApiProvider::DeepseekCN
1752 ) {
1753 if persist {
1754 match persist_root_string_key(app.config_path.as_deref(), "base_url", &value) {
1755 Ok(path) => {
1756 return CommandResult::message(format!(
1757 "provider_url = {value} (saved to {}; restart required)",
1758 path.display()
1759 ));
1760 }
1761 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1762 }
1763 }
1764 } else if persist {
1765 match persist_provider_base_url_key(
1766 app.config_path.as_deref(),
1767 app.api_provider,
1768 &value,
1769 ) {
1770 Ok(path) => {
1771 return CommandResult::message(format!(
1772 "provider_url = {value} for {} (saved to {}; restart required)",
1773 app.api_provider.as_str(),
1774 path.display()
1775 ));
1776 }
1777 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1778 }
1779 }
1780 return CommandResult::error(
1781 "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
1782 );
1783 }
1784 "stream_chunk_timeout_secs" => {
1785 let raw = match value.trim().parse::<u64>() {
1786 Ok(value) => value,
1787 Err(_) => {
1788 return CommandResult::error(
1789 "stream_chunk_timeout_secs must be a whole number",
1790 );
1791 }
1792 };
1793 if raw != 0
1794 && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw)
1795 {
1796 return CommandResult::error(format!(
1797 "stream_chunk_timeout_secs must be 0 or {MIN_STREAM_CHUNK_TIMEOUT_SECS}..={MAX_STREAM_CHUNK_TIMEOUT_SECS}"
1798 ));
1799 }
1800 let resolved = if raw == 0 {
1801 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
1802 } else {
1803 raw
1804 };
1805 app.stream_chunk_timeout_secs = resolved;
1806 let value_label = stream_chunk_timeout_value_label(raw, resolved);
1807 if persist {
1808 match persist_tui_integer_key(
1809 app.config_path.as_deref(),
1810 "stream_chunk_timeout_secs",
1811 raw,
1812 ) {
1813 Ok(path) => {
1814 return CommandResult::with_message_and_action(
1815 format!(
1816 "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)",
1817 path.display()
1818 ),
1819 AppAction::UpdateStreamChunkTimeout(resolved),
1820 );
1821 }
1822 Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
1823 }
1824 }
1825 return CommandResult::with_message_and_action(
1826 format!(
1827 "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)"
1828 ),
1829 AppAction::UpdateStreamChunkTimeout(resolved),
1830 );
1831 }
1832 _ => {}
1833 }
1834
1835 // This copy exists to validate the value and to project it onto live `App`
1836 // state. It is deliberately *not* what gets saved: see
1837 // [`persist_single_setting`].
1838 let mut settings = match Settings::load_persisted() {
1839 Ok(s) => s,
1840 Err(e) if !persist => {
1841 app.status_message = Some(format!(
1842 "Settings unavailable; applying session-only override ({e})"
1843 ));
1844 Settings::default()
1845 }
1846 Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
1847 };
1848
1849 if key == "default_model"
1850 && !matches!(
1851 app.api_provider,
1852 ApiProvider::Deepseek | ApiProvider::DeepseekCN
1853 )
1854 && !persist
1855 {
1856 return CommandResult::error(format!(
1857 "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.",
1858 app.api_provider.as_str()
1859 ));
1860 }
1861
1862 if let Err(e) = settings.set(&key, value) {
1863 return CommandResult::error(format!("{e}"));
1864 }
1865 // Runtime/environment constraints are an effective projection, not saved
1866 // preferences. Keep the persisted copy pristine so NO_ANIMATIONS or a
1867 // terminal quirk cannot become permanent during an unrelated edit.
1868 let mut effective_settings = settings.clone();
1869 effective_settings.apply_env_overrides();
1870
1871 let mut action = None;
1872 match key.as_str() {
1873 "auto_compact" | "compact" => {
1874 app.auto_compact = settings.auto_compact;
1875 app.auto_compact_user_configured = true;
1876 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
1877 }
1878 "auto_compact_threshold" | "auto_compact_threshold_percent" => {
1879 app.auto_compact = true;
1880 app.auto_compact_user_configured = true;
1881 app.auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
1882 app.update_model_compaction_budget();
1883 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
1884 }
1885 "calm_mode" | "calm" => {
1886 app.calm_mode = settings.calm_mode;
1887 app.mark_history_updated();
1888 }
1889 "low_motion" | "motion" => {
1890 app.low_motion = effective_settings.low_motion;
1891 app.needs_redraw = true;
1892 }
1893 "fancy_animations" | "fancy" | "animations" => {
1894 app.fancy_animations = effective_settings.fancy_animations;
1895 app.needs_redraw = true;
1896 }
1897 "ocean_treatment" | "treatment" | "background_treatment" => {
1898 app.ocean_treatment =
1899 crate::tui::ocean::OceanTreatment::parse(&settings.ocean_treatment);
1900 app.needs_redraw = true;
1901 }
1902 "focus_texture" | "texture" => {
1903 app.focus_texture =
1904 crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture)
1905 .unwrap_or_default();
1906 app.needs_redraw = true;
1907 }
1908 "work_surface_placement" | "work_surface" | "work_rail" => {
1909 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::parse(
1910 &settings.work_surface_placement,
1911 );
1912 app.work_surface.focused = false;
1913 app.work_surface.last_area = None;
1914 app.needs_redraw = true;
1915 }
1916 "rail_panel" | "rail" => {
1917 app.work_surface.panel =
1918 crate::tui::work_surface::RailPanel::parse(&settings.rail_panel);
1919 app.needs_redraw = true;
1920 }
1921 "work_surface_top_height" | "work_top_height" => {
1922 app.work_surface.top_height = settings.work_surface_top_height;
1923 app.needs_redraw = true;
1924 }
1925 "work_surface_side_width" | "work_side_width" => {
1926 app.work_surface.side_width = settings.work_surface_side_width;
1927 app.needs_redraw = true;
1928 }
1929 "bracketed_paste" | "paste" => {
1930 app.use_bracketed_paste = settings.bracketed_paste;
1931 app.needs_redraw = true;
1932 }
1933 "status_indicator" | "indicator" => {
1934 app.status_indicator = settings.status_indicator.clone();
1935 app.needs_redraw = true;
1936 }
1937 "synchronized_output" | "sync_output" | "sync" => {
1938 app.synchronized_output_enabled = effective_settings.synchronized_output_enabled();
1939 app.needs_redraw = true;
1940 }
1941 "show_thinking" | "thinking" => {
1942 app.show_thinking = settings.show_thinking;
1943 app.mark_history_updated();
1944 }
1945 "thinking_default_expanded" | "thinking_expanded" => {
1946 app.thinking_default_expanded = settings.thinking_default_expanded;
1947 app.mark_history_updated();
1948 }
1949 "thinking_highlight" | "reasoning_highlight" => {
1950 app.thinking_highlight = settings.thinking_highlight;
1951 app.mark_history_updated();
1952 }
1953 "show_tool_details" | "tool_details" => {
1954 app.show_tool_details = settings.show_tool_details;
1955 app.mark_history_updated();
1956 }
1957 "inline_diffs" | "inline_diff" | "diffs" => {
1958 app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs);
1959 app.mark_history_updated();
1960 app.needs_redraw = true;
1961 }
1962 "locale" | "language" => {
1963 app.ui_locale = resolve_locale(&settings.locale);
1964 app.mark_history_updated();
1965 app.needs_redraw = true;
1966 }
1967 "theme" | "ui_theme" | "background_color" | "background" | "bg" => {
1968 // Theme previews reload persisted settings for each cursor move.
1969 // Keep a session-only background overlay live unless this command
1970 // is itself updating (or clearing) the background.
1971 let background_color_override = if matches!(key.as_str(), "theme" | "ui_theme") {
1972 app.background_color_override
1973 } else {
1974 settings
1975 .background_color
1976 .as_deref()
1977 .and_then(crate::palette::parse_hex_rgb_color)
1978 };
1979 let background_setting =
1980 background_color_override.and_then(crate::palette::hex_rgb_string);
1981 let (_, theme_id, ui_theme) = match crate::palette::resolve_theme_setting(
1982 &settings.theme,
1983 background_setting.as_deref(),
1984 ) {
1985 Ok(resolved) => resolved,
1986 Err(error) => {
1987 return CommandResult::error(format!("Failed to apply theme: {error}"));
1988 }
1989 };
1990 app.background_color_override = background_color_override;
1991 app.theme_id = theme_id;
1992 app.ui_theme = ui_theme;
1993 app.needs_redraw = true;
1994 }
1995 "cost_currency" | "currency" => {
1996 app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
1997 .unwrap_or(crate::pricing::CostCurrency::Usd);
1998 app.needs_redraw = true;
1999 }
2000 "composer_density" | "composer" => {
2001 app.composer_density =
2002 crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
2003 app.needs_redraw = true;
2004 }
2005 "composer_border" | "border" => {
2006 app.composer_border = settings.composer_border;
2007 app.needs_redraw = true;
2008 }
2009 "composer_vim_mode" | "vim_mode" | "vim" => {
2010 app.composer.vim_enabled = settings.composer_vim_mode == "vim";
2011 app.composer.vim_mode = if app.composer.vim_enabled {
2012 VimMode::Normal
2013 } else {
2014 VimMode::Insert
2015 };
2016 app.composer.vim_pending_d = false;
2017 app.needs_redraw = true;
2018 }
2019 "paste_burst_detection" | "paste_burst" => {
2020 app.use_paste_burst_detection = settings.paste_burst_detection;
2021 if !app.use_paste_burst_detection {
2022 app.paste_burst.clear_after_explicit_paste();
2023 }
2024 }
2025 "mention_menu_limit" | "mention_limit" => {
2026 app.mention_menu_limit = settings.mention_menu_limit;
2027 app.composer.mention_completion_cache = None;
2028 app.composer.mention_discovery.invalidate();
2029 app.needs_redraw = true;
2030 }
2031 "mention_menu_behavior" | "mention_behavior" | "mention_menu" => {
2032 app.mention_menu_behavior = settings.mention_menu_behavior.clone();
2033 app.composer.mention_completion_cache = None;
2034 app.composer.mention_discovery.invalidate();
2035 app.needs_redraw = true;
2036 }
2037 "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
2038 app.mention_walk_depth = settings.mention_walk_depth;
2039 app.composer.mention_completion_cache = None;
2040 app.composer.mention_discovery.invalidate();
2041 app.needs_redraw = true;
2042 }
2043 "workspace_follow_symlinks" | "follow_symlinks" => {
2044 app.workspace_follow_symlinks = settings.workspace_follow_symlinks;
2045 app.composer.mention_completion_cache = None;
2046 app.composer.mention_discovery.invalidate();
2047 app.needs_redraw = true;
2048 // Engine tools use EngineConfig which is fixed at startup
2049 return CommandResult::message(if persist {
2050 if let Err(e) = persist_single_setting(&key, value) {
2051 return CommandResult::error(format!("Failed to save: {e}"));
2052 }
2053 format!(
2054 "workspace_follow_symlinks = {} (saved; restart required for engine tools)",
2055 settings.workspace_follow_symlinks
2056 )
2057 } else {
2058 format!(
2059 "workspace_follow_symlinks = {} (session only for UI; restart required for engine tools)",
2060 settings.workspace_follow_symlinks
2061 )
2062 });
2063 }
2064 "transcript_spacing" | "spacing" => {
2065 app.transcript_spacing =
2066 crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
2067 app.mark_history_updated();
2068 }
2069 "tool_collapse" | "tool_collapse_mode" | "collapse" => {
2070 app.tool_collapse_mode =
2071 crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode);
2072 app.expanded_tool_runs.clear();
2073 app.mark_history_updated();
2074 }
2075 // `default_mode` is a restart default, not a live mode switch. The
2076 // `/mode` command owns synchronized session transitions.
2077 "default_mode" => {}
2078 "mode" => {
2079 let mode = AppMode::from_setting(&settings.default_mode);
2080 app.set_mode(mode);
2081 action = Some(AppAction::ModeChanged(mode));
2082 }
2083 "max_history" | "history" => {
2084 app.max_input_history = settings.max_input_history;
2085 }
2086 "default_model" => {
2087 if matches!(
2088 app.api_provider,
2089 ApiProvider::Deepseek | ApiProvider::DeepseekCN
2090 ) && let Some(ref model) = settings.default_model
2091 {
2092 app.set_model_selection(model.clone());
2093 app.update_model_compaction_budget();
2094 app.session.last_prompt_tokens = None;
2095 app.session.last_completion_tokens = None;
2096 app.session.last_output_throughput = None;
2097 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
2098 }
2099 }
2100 "reasoning_effort" | "effort" => {
2101 app.reasoning_effort_preference = settings
2102 .reasoning_effort
2103 .as_deref()
2104 .map(ReasoningEffort::from_setting);
2105 app.reasoning_effort = app.reasoning_effort_preference.map_or_else(
2106 || {
2107 if app.auto_model {
2108 ReasoningEffort::Auto
2109 } else {
2110 ReasoningEffort::default()
2111 }
2112 },
2113 |requested| {
2114 if app.auto_model {
2115 requested
2116 } else {
2117 requested.normalize_for_provider(app.api_provider)
2118 }
2119 },
2120 );
2121 app.invalidate_route_receipts_for_reasoning_change();
2122 app.update_model_compaction_budget();
2123 action = Some(AppAction::UpdateCompaction(app.compaction_config()));
2124 }
2125 "context_panel" | "context" | "session_panel" => {
2126 app.context_panel = settings.context_panel;
2127 app.needs_redraw = true;
2128 }
2129 "sessions_rail" | "sessions_panel" | "session_rail" => {
2130 app.sessions_rail = settings.sessions_rail;
2131 app.needs_redraw = true;
2132 }
2133 _ => {}
2134 }
2135
2136 let display_value = match key.as_str() {
2137 "default_mode" | "mode" => settings.default_mode.clone(),
2138 "cost_currency" | "currency" => settings.cost_currency.clone(),
2139 "theme" | "ui_theme" => settings.theme.clone(),
2140 "synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(),
2141 "background_color" | "background" | "bg" => settings
2142 .background_color
2143 .clone()
2144 .unwrap_or_else(|| "default".to_string()),
2145 "reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else(
2146 || "config/default".to_string(),
2147 |value| {
2148 ReasoningEffort::from_setting_for_provider(value, app.api_provider)
2149 .as_setting_for_provider(app.api_provider)
2150 .to_string()
2151 },
2152 ),
2153 "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(),
2154 "low_motion" | "motion" => settings.low_motion.to_string(),
2155 "fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(),
2156 _ => value.to_string(),
2157 };
2158
2159 let mut message = if persist {
2160 if let Err(e) = persist_single_setting(&key, value) {
2161 return CommandResult::error(format!("Failed to save: {e}"));
2162 }
2163 format!("{key} = {display_value} (saved)")
2164 } else {
2165 format!("{key} = {display_value} (session only, add --save to persist)")
2166 };
2167 if key == "default_model"
2168 && !matches!(
2169 app.api_provider,
2170 ApiProvider::Deepseek | ApiProvider::DeepseekCN
2171 )
2172 {
2173 message.push_str(&format!(
2174 "; DeepSeek fallback only — active {}/{} is unchanged",
2175 app.api_provider.as_str(),
2176 app.model_display_label()
2177 ));
2178 }
2179
2180 CommandResult {
2181 message: Some(message),
2182 action,
2183 is_error: false,
2184 }
2185 }
2186
2187 /// Persist exactly the one key `/set --save` changed.
2188 ///
2189 /// `/set` loads a `Settings` copy up front to validate the value and to project
2190 /// it onto live `App` state, and a lot of `App` mutation happens in between. That
2191 /// copy is a stale snapshot by the time we get here, so saving *it* would write
2192 /// back every other field as it looked before — reverting any mode, thinking,
2193 /// model, or permission write that landed in the meantime. Re-applying the single
2194 /// key inside [`Settings::transact`] persists the user's actual edit and nothing
2195 /// else. `Settings::set` is the same normalizer the copy above already accepted
2196 /// the value through, so this cannot fail for a value that validated.
2197 fn persist_single_setting(key: &str, value: &str) -> anyhow::Result<()> {
2198 Settings::transact(|settings| settings.set(key, value))
2199 }
2200
2201 /// Select the TUI operating mode.
2202 pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
2203 let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else {
2204 return CommandResult::action(AppAction::OpenModePicker);
2205 };
2206 match AppMode::parse(arg) {
2207 Some(mode) => {
2208 let (message, changed) = switch_mode_with_status(app, mode);
2209 if changed {
2210 CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode))
2211 } else {
2212 CommandResult::message(message)
2213 }
2214 }
2215 None => CommandResult::error("Usage: /mode [act|agent|plan|operate|1|2|3]"),
2216 }
2217 }
2218
2219 pub fn switch_mode(app: &mut App, mode: AppMode) -> String {
2220 switch_mode_with_status(app, mode).0
2221 }
2222
2223 /// Returns the user-facing sentence and whether live mode moved (the caller
2224 /// emits `AppAction::ModeChanged` only for the latter).
2225 ///
2226 /// The three outcomes read differently on purpose. Before the typed
2227 /// [`SettingSelection`], a refusal and a same-mode selection that *did* persist
2228 /// the startup default both came back as "Already in X mode." — so the one case
2229 /// where `/mode` had written something looked exactly like the case where it had
2230 /// written nothing.
2231 fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
2232 match app.select_mode(mode) {
2233 SettingSelection::Changed => (format!("Switched to {} mode.", mode.display_name()), true),
2234 SettingSelection::PersistedSame => (app.mode_startup_default_receipt(mode), false),
2235 SettingSelection::Refused => (
2236 app.setting_locked_message(MessageId::SettingSubjectMode),
2237 false,
2238 ),
2239 }
2240 }
2241
2242 /// `/theme [name]` — with no argument, open the interactive picker (arrow
2243 /// keys, live preview, Enter to persist, Esc to revert). With an argument,
2244 /// route through `set_config_value("theme", ...)` so the apply + save flow is
2245 /// shared with `/config`.
2246 pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
2247 match arg.map(str::trim).filter(|s| !s.is_empty()) {
2248 None => CommandResult::action(AppAction::OpenThemePicker),
2249 Some("schema") => CommandResult::message(crate::palette::user_theme_schema_json()),
2250 Some("path") => match crate::palette::user_themes_dir() {
2251 Ok(path) => CommandResult::message(format!(
2252 "User themes: {}\nSelect with: /theme custom:<name>",
2253 path.display()
2254 )),
2255 Err(error) => CommandResult::error(error),
2256 },
2257 Some(name) => set_config_value(app, "theme", name, true),
2258 }
2259 }
2260
2261 /// Manage workspace-level trust and the per-path allowlist.
2262 ///
2263 /// Subcommands:
2264 /// - `/trust` – show current state and trusted external paths
2265 /// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
2266 /// - `/trust off` – disable workspace-level trust mode
2267 /// - `/trust add <path>` – add a directory to the allowlist (#29)
2268 /// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
2269 /// - `/trust list` – list trusted external paths for this workspace
2270 pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
2271 let raw = arg.map(str::trim).unwrap_or("");
2272 let mut parts = raw.splitn(2, char::is_whitespace);
2273 let sub = parts.next().unwrap_or("").to_lowercase();
2274 let rest = parts.next().map(str::trim).unwrap_or("");
2275 let workspace = app.workspace.clone();
2276
2277 match sub.as_str() {
2278 "" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
2279 "on" | "enable" | "yes" | "y" => {
2280 app.trust_mode = true;
2281 CommandResult::message(
2282 "Workspace trust mode enabled — agent file tools can now read/write any path. \
2283 Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
2284 )
2285 }
2286 "off" | "disable" | "no" | "n" => {
2287 app.trust_mode = false;
2288 CommandResult::message("Workspace trust mode disabled.")
2289 }
2290 "add" => trust_add(&workspace, rest),
2291 "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
2292 other => CommandResult::error(format!(
2293 "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
2294 )),
2295 }
2296 }
2297
2298 fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
2299 let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
2300 let mut lines = Vec::new();
2301 lines.push(format!(
2302 "Workspace trust mode: {}",
2303 if app.trust_mode {
2304 "enabled"
2305 } else {
2306 "disabled"
2307 }
2308 ));
2309 if trust.paths().is_empty() {
2310 if force_paths {
2311 lines.push("No external paths trusted from this workspace.".to_string());
2312 } else {
2313 lines.push(
2314 "No external paths trusted yet. Use `/trust add <path>` to allow a directory."
2315 .to_string(),
2316 );
2317 }
2318 } else {
2319 lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
2320 for path in trust.paths() {
2321 lines.push(format!(" • {}", path.display()));
2322 }
2323 }
2324 CommandResult::message(lines.join("\n"))
2325 }
2326
2327 fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
2328 if raw.is_empty() {
2329 return CommandResult::error(
2330 "Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
2331 );
2332 }
2333 let path = PathBuf::from(expand_tilde(raw));
2334 if !path.exists() {
2335 return CommandResult::error(format!(
2336 "Path not found: {} — supply an existing directory or file.",
2337 path.display()
2338 ));
2339 }
2340 match crate::workspace_trust::add(workspace, &path) {
2341 Ok(stored) => CommandResult::message(format!(
2342 "Added to trust list for this workspace: {}",
2343 stored.display()
2344 )),
2345 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
2346 }
2347 }
2348
2349 fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
2350 if raw.is_empty() {
2351 return CommandResult::error("Usage: /trust remove <path>");
2352 }
2353 let path = PathBuf::from(expand_tilde(raw));
2354 match crate::workspace_trust::remove(workspace, &path) {
2355 Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
2356 Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
2357 Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
2358 }
2359 }
2360
2361 fn expand_tilde(raw: &str) -> String {
2362 if let Some(rest) = raw.strip_prefix("~/")
2363 && let Some(home) = crate::config::effective_home_dir()
2364 {
2365 return home.join(rest).to_string_lossy().into_owned();
2366 } else if raw == "~"
2367 && let Some(home) = crate::config::effective_home_dir()
2368 {
2369 return home.to_string_lossy().into_owned();
2370 }
2371 raw.to_string()
2372 }
2373
2374 /// Toggle LSP diagnostics on/off or show status.
2375 ///
2376 /// - `/lsp on` — enable inline LSP diagnostics
2377 /// - `/lsp off` — disable inline LSP diagnostics
2378 /// - `/lsp status` — show whether diagnostics are currently enabled
2379 pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
2380 let raw = arg.map(str::trim).unwrap_or("");
2381 // Access lsp_manager config through the App's engine handle
2382 let current_enabled = app.lsp_enabled;
2383
2384 match raw {
2385 "" | "status" => {
2386 let status = if current_enabled { "on" } else { "off" };
2387 CommandResult::message(format!(
2388 "LSP diagnostics are currently **{status}**.\n\n\
2389 Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits."
2390 ))
2391 }
2392 "on" | "enable" | "1" | "true" => {
2393 app.lsp_enabled = true;
2394 CommandResult::message(
2395 "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.",
2396 )
2397 }
2398 "off" | "disable" | "0" | "false" => {
2399 app.lsp_enabled = false;
2400 CommandResult::message("LSP diagnostics disabled.")
2401 }
2402 other => CommandResult::error(format!(
2403 "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`."
2404 )),
2405 }
2406 }
2407
2408 /// Logout - clear the active provider's saved API key and return to
2409 /// onboarding. The on-disk scrub targets the user-global config document
2410 /// (#5193) and the provider's durable secret-store slot is deleted too, so
2411 /// the cleared key cannot reappear through the read chain (#5196). Exact
2412 /// named custom providers clear only their own table (cae14f4b9). For a
2413 /// full every-provider wipe, use `codewhale auth logout`; for single-provider
2414 /// key replacement, use `codewhale auth clear --provider <id>` and
2415 /// `codewhale auth set --provider <id>`.
2416 pub fn logout(app: &mut App) -> CommandResult {
2417 let provider_name = app.provider_identity_for_persistence().to_string();
2418 match clear_active_provider_api_key(&provider_name) {
2419 Ok(()) => {
2420 app.onboarding = OnboardingState::Provider;
2421 app.onboarding_needs_api_key = true;
2422 app.onboarding_provider = app.api_provider;
2423 app.onboarding_missing_key_recovery = true;
2424 app.api_key_env_only = false;
2425 CommandResult::with_message_and_action(
2426 format!(
2427 "Cleared API key for {provider_name}. \
2428 Use `codewhale auth clear --provider <id>` to clear a different provider."
2429 ),
2430 AppAction::OpenProviderPicker,
2431 )
2432 }
2433 Err(e) => CommandResult::error(format!("Failed to clear API key for {provider_name}: {e}")),
2434 }
2435 }
2436
2437 #[cfg(test)]
2438 mod tests {
2439 use super::*;
2440 use crate::config::Config;
2441 use crate::test_support::lock_test_env;
2442 use crate::tui::app::{App, TuiOptions};
2443 use crate::tui::approval::ApprovalMode;
2444 use std::env;
2445 use std::ffi::OsString;
2446 use std::fs;
2447 use std::path::Path;
2448 use std::path::PathBuf;
2449 use std::time::{SystemTime, UNIX_EPOCH};
2450
2451 struct EnvGuard {
2452 home: Option<OsString>,
2453 userprofile: Option<OsString>,
2454 codewhale_config_path: Option<OsString>,
2455 deepseek_config_path: Option<OsString>,
2456 codewhale_allow_shell: Option<OsString>,
2457 deepseek_allow_shell: Option<OsString>,
2458 deepseek_approval_policy: Option<OsString>,
2459 no_animations: Option<OsString>,
2460 term_program: Option<OsString>,
2461 ptyxis_version: Option<OsString>,
2462 _lock: crate::test_support::TestEnvLock,
2463 }
2464
2465 impl EnvGuard {
2466 fn new(home: &Path) -> Self {
2467 let lock = crate::test_support::lock_test_env();
2468 let home_str = OsString::from(home.as_os_str());
2469 let config_path = home.join(".deepseek").join("config.toml");
2470 let config_str = OsString::from(config_path.as_os_str());
2471 let home_prev = env::var_os("HOME");
2472 let userprofile_prev = env::var_os("USERPROFILE");
2473 let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
2474 let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
2475 let codewhale_allow_shell_prev = env::var_os("CODEWHALE_ALLOW_SHELL");
2476 let deepseek_allow_shell_prev = env::var_os("DEEPSEEK_ALLOW_SHELL");
2477 let deepseek_approval_policy_prev = env::var_os("DEEPSEEK_APPROVAL_POLICY");
2478 let no_animations_prev = env::var_os("NO_ANIMATIONS");
2479 let term_program_prev = env::var_os("TERM_PROGRAM");
2480 let ptyxis_version_prev = env::var_os("PTYXIS_VERSION");
2481
2482 // Safety: test-only environment mutation guarded by process-wide mutex.
2483 unsafe {
2484 env::set_var("HOME", &home_str);
2485 env::set_var("USERPROFILE", &home_str);
2486 env::remove_var("CODEWHALE_CONFIG_PATH");
2487 env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
2488 env::remove_var("CODEWHALE_ALLOW_SHELL");
2489 env::remove_var("DEEPSEEK_ALLOW_SHELL");
2490 env::remove_var("DEEPSEEK_APPROVAL_POLICY");
2491 env::remove_var("NO_ANIMATIONS");
2492 env::remove_var("TERM_PROGRAM");
2493 env::remove_var("PTYXIS_VERSION");
2494 }
2495
2496 Self {
2497 home: home_prev,
2498 userprofile: userprofile_prev,
2499 codewhale_config_path: codewhale_config_prev,
2500 deepseek_config_path: deepseek_config_prev,
2501 codewhale_allow_shell: codewhale_allow_shell_prev,
2502 deepseek_allow_shell: deepseek_allow_shell_prev,
2503 deepseek_approval_policy: deepseek_approval_policy_prev,
2504 no_animations: no_animations_prev,
2505 term_program: term_program_prev,
2506 ptyxis_version: ptyxis_version_prev,
2507 _lock: lock,
2508 }
2509 }
2510 }
2511
2512 impl Drop for EnvGuard {
2513 fn drop(&mut self) {
2514 if let Some(value) = self.home.take() {
2515 // Safety: test-only environment mutation guarded by a global mutex.
2516 unsafe {
2517 env::set_var("HOME", value);
2518 }
2519 } else {
2520 // Safety: test-only environment mutation guarded by a global mutex.
2521 unsafe {
2522 env::remove_var("HOME");
2523 }
2524 }
2525
2526 if let Some(value) = self.userprofile.take() {
2527 // Safety: test-only environment mutation guarded by a global mutex.
2528 unsafe {
2529 env::set_var("USERPROFILE", value);
2530 }
2531 } else {
2532 // Safety: test-only environment mutation guarded by a global mutex.
2533 unsafe {
2534 env::remove_var("USERPROFILE");
2535 }
2536 }
2537
2538 if let Some(value) = self.codewhale_config_path.take() {
2539 // Safety: test-only environment mutation guarded by a global mutex.
2540 unsafe {
2541 env::set_var("CODEWHALE_CONFIG_PATH", value);
2542 }
2543 } else {
2544 // Safety: test-only environment mutation guarded by a global mutex.
2545 unsafe {
2546 env::remove_var("CODEWHALE_CONFIG_PATH");
2547 }
2548 }
2549
2550 if let Some(value) = self.deepseek_config_path.take() {
2551 // Safety: test-only environment mutation guarded by a global mutex.
2552 unsafe {
2553 env::set_var("DEEPSEEK_CONFIG_PATH", value);
2554 }
2555 } else {
2556 // Safety: test-only environment mutation guarded by a global mutex.
2557 unsafe {
2558 env::remove_var("DEEPSEEK_CONFIG_PATH");
2559 }
2560 }
2561
2562 for (key, value) in [
2563 ("CODEWHALE_ALLOW_SHELL", self.codewhale_allow_shell.take()),
2564 ("DEEPSEEK_ALLOW_SHELL", self.deepseek_allow_shell.take()),
2565 (
2566 "DEEPSEEK_APPROVAL_POLICY",
2567 self.deepseek_approval_policy.take(),
2568 ),
2569 ] {
2570 // Safety: test-only environment mutation guarded by a global mutex.
2571 unsafe {
2572 if let Some(value) = value {
2573 env::set_var(key, value);
2574 } else {
2575 env::remove_var(key);
2576 }
2577 }
2578 }
2579
2580 if let Some(value) = self.no_animations.take() {
2581 // Safety: test-only environment mutation guarded by a global mutex.
2582 unsafe {
2583 env::set_var("NO_ANIMATIONS", value);
2584 }
2585 } else {
2586 // Safety: test-only environment mutation guarded by a global mutex.
2587 unsafe {
2588 env::remove_var("NO_ANIMATIONS");
2589 }
2590 }
2591
2592 for (key, value) in [
2593 ("TERM_PROGRAM", self.term_program.take()),
2594 ("PTYXIS_VERSION", self.ptyxis_version.take()),
2595 ] {
2596 // Safety: test-only environment mutation guarded by a global mutex.
2597 unsafe {
2598 if let Some(value) = value {
2599 env::set_var(key, value);
2600 } else {
2601 env::remove_var(key);
2602 }
2603 }
2604 }
2605 }
2606 }
2607
2608 fn create_test_app_with_config(config: &Config) -> App {
2609 let options = TuiOptions {
2610 model: "test-model".to_string(),
2611 // Keep command tests independent from the developer's saved
2612 // `default_mode` setting: with `false`, App::new starts in the
2613 // saved mode, so a machine with `default_mode = "yolo"` flips
2614 // `allow_shell` on and breaks the allow_shell assertions.
2615 start_in_agent_mode: true,
2616 skip_onboarding: false,
2617 ..crate::test_support::test_tui_options(PathBuf::from("."))
2618 };
2619 let mut app = App::new(options, config);
2620 // App::new folds in saved TUI settings from the developer machine.
2621 // Pin command tests back to DeepSeek semantics so model aliases are
2622 // not normalized through a provider selected in an interactive run.
2623 app.model = "test-model".to_string();
2624 app.auto_model = false;
2625 app.api_provider = crate::config::ApiProvider::Deepseek;
2626 app.model_ids_passthrough = false;
2627 app
2628 }
2629
2630 fn create_test_app() -> App {
2631 create_test_app_with_config(&Config::default())
2632 }
2633
2634 /// The shipped preset must survive its own preflight, or `/config preset
2635 /// calm` would be refused for a reason the user cannot act on.
2636 #[test]
2637 fn the_shipped_preset_passes_its_own_preflight() {
2638 let app = create_test_app();
2639 let fields = crate::settings::preset_fields("calm").expect("the calm preset exists");
2640 assert_eq!(preset_preflight(&app, fields), None);
2641 }
2642
2643 /// A field the setter would reject must be caught *before* the transaction
2644 /// opens. Previously the bundle was saved first and the per-field mirror
2645 /// pass then failed, leaving the user with an error message and a rewritten
2646 /// settings file.
2647 #[test]
2648 fn preset_preflight_refuses_an_invalid_field_before_any_write() {
2649 let app = create_test_app();
2650 let refusal = preset_preflight(&app, &[("calm_mode", "true"), ("low_motion", "banana")])
2651 .expect("an invalid value must be refused");
2652 assert!(
2653 refusal.contains("low_motion"),
2654 "the refusal must name the offending field, got {refusal:?}"
2655 );
2656 }
2657
2658 /// A preset carrying a live-route key is refused whole while a turn runs,
2659 /// rather than saving the bundle and then failing on that one field.
2660 #[test]
2661 fn preset_preflight_refuses_a_live_route_field_while_a_turn_runs() {
2662 let mut app = create_test_app();
2663 app.is_loading = true;
2664 let bundle = [("calm_mode", "true"), ("reasoning_effort", "high")];
2665 let refusal =
2666 preset_preflight(&app, &bundle).expect("a live-route field must be refused mid-turn");
2667 assert!(
2668 refusal.contains("locked while a turn is running"),
2669 "got {refusal:?}"
2670 );
2671
2672 app.is_loading = false;
2673 assert_eq!(
2674 preset_preflight(&app, &bundle),
2675 None,
2676 "the same bundle must apply once the turn ends"
2677 );
2678 }
2679
2680 /// The refusal list is the contract for #2982 on the slash surfaces. Keep
2681 /// restart-only `default_mode` out of it: `set_config_value` deliberately
2682 /// does not apply that key to the live session.
2683 #[test]
2684 fn live_route_key_list_covers_every_route_mutating_alias() {
2685 for key in [
2686 "mode",
2687 "model",
2688 "default_model",
2689 "reasoning_effort",
2690 "effort",
2691 "provider",
2692 "approval_mode",
2693 "approval_policy",
2694 "approval",
2695 ] {
2696 assert!(
2697 live_route_setting_subject(key).is_some(),
2698 "{key} mutates the active route and must be locked mid-turn"
2699 );
2700 }
2701 for key in ["default_mode", "theme", "calm_mode", "rail_panel"] {
2702 assert!(
2703 live_route_setting_subject(key).is_none(),
2704 "{key} does not mutate the active route and must stay settable"
2705 );
2706 }
2707 }
2708
2709 #[test]
2710 fn approval_aliases_are_inert_while_a_turn_is_running() {
2711 let mut app = create_test_app();
2712 app.approval_mode = ApprovalMode::Suggest;
2713 app.is_loading = true;
2714
2715 for key in ["approval_mode", "approval_policy", "approval"] {
2716 let result = set_config_value(&mut app, key, "never", false);
2717 assert!(result.is_error, "{key} must be refused mid-turn");
2718 assert!(
2719 result
2720 .message
2721 .as_deref()
2722 .is_some_and(|message| message.contains("locked while a turn is running")),
2723 "unexpected refusal for {key}: {:?}",
2724 result.message
2725 );
2726 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
2727 }
2728 }
2729
2730 #[test]
2731 fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() {
2732 let mut app = create_test_app();
2733 app.calm_mode = false;
2734 app.show_thinking = true;
2735 app.show_tool_details = true;
2736 app.fancy_animations = true;
2737
2738 let result = config_command(&mut app, Some("preset calm"));
2739 let message = result.message.unwrap_or_default();
2740 assert!(
2741 message.contains("calm"),
2742 "summary should name the preset: {message}"
2743 );
2744
2745 assert!(app.calm_mode);
2746 assert!(!app.show_tool_details);
2747 assert!(app.low_motion);
2748 assert!(!app.fancy_animations);
2749 assert_eq!(
2750 app.tool_collapse_mode,
2751 crate::tui::app::ToolCollapseMode::Calm
2752 );
2753 assert_eq!(
2754 app.transcript_spacing,
2755 crate::tui::app::TranscriptSpacing::Compact
2756 );
2757 // Evidence preserved: thinking is not hidden by the preset.
2758 assert!(app.show_thinking, "calm preset must not hide thinking");
2759 }
2760
2761 #[test]
2762 fn config_preset_unknown_name_reports_error() {
2763 let mut app = create_test_app();
2764 let result = config_command(&mut app, Some("preset turbo"));
2765 let message = result.message.unwrap_or_default();
2766 assert!(
2767 message.to_lowercase().contains("unknown preset"),
2768 "expected unknown-preset error, got: {message}"
2769 );
2770 }
2771
2772 #[test]
2773 fn config_preset_save_without_name_reports_usage() {
2774 let mut app = create_test_app();
2775 let result = config_command(&mut app, Some("preset --save"));
2776 let message = result.message.unwrap_or_default();
2777 assert!(
2778 message.contains("Usage: /config preset"),
2779 "expected usage hint, got: {message}"
2780 );
2781 assert!(!result.is_error);
2782 }
2783
2784 #[test]
2785 fn work_surface_config_applies_live_and_rejects_bottom() {
2786 let mut app = create_test_app();
2787
2788 let result = set_config_value(&mut app, "work_surface_placement", "left", false);
2789 assert!(!result.is_error, "{:?}", result.message);
2790 assert_eq!(
2791 app.work_surface.placement,
2792 crate::tui::work_surface::WorkSurfacePlacement::Left
2793 );
2794 let shown = show_single_setting(&app, "work_surface_placement");
2795 assert_eq!(
2796 shown.message.as_deref(),
2797 Some("work_surface_placement = left")
2798 );
2799
2800 let result = set_config_value(&mut app, "work_surface_placement", "bottom", false);
2801 assert!(result.is_error);
2802 assert_eq!(
2803 app.work_surface.placement,
2804 crate::tui::work_surface::WorkSurfacePlacement::Left
2805 );
2806 }
2807
2808 #[test]
2809 fn rail_command_on_restores_default_top_placement() {
2810 let mut app = create_test_app();
2811 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off;
2812
2813 let result = sidebar(&mut app, Some("on"));
2814
2815 assert!(!result.is_error);
2816 assert_eq!(
2817 app.work_surface.placement,
2818 crate::tui::work_surface::WorkSurfacePlacement::Top
2819 );
2820 let message = result.message.unwrap_or_default();
2821 assert!(message.contains("top placement"), "got: {message}");
2822 }
2823
2824 #[test]
2825 fn rail_command_reports_narrow_terminal_top_fallback() {
2826 let mut app = create_test_app();
2827 app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left;
2828 // A 60-column host is below the side-rail floor, so the effective
2829 // placement falls back to top; the status must say so rather than
2830 // claim a left rail renders.
2831 let _ = crate::tui::work_surface::height(&mut app, 60, 24, u16::MAX);
2832
2833 let result = sidebar(&mut app, None);
2834
2835 assert!(!result.is_error);
2836 let message = result.message.unwrap_or_default();
2837 assert!(message.contains("left placement"), "got: {message}");
2838 assert!(message.contains("showing top for now"), "got: {message}");
2839 }
2840
2841 #[test]
2842 fn rail_command_off_never_claims_visibility() {
2843 let mut app = create_test_app();
2844
2845 let result = sidebar(&mut app, Some("off"));
2846
2847 assert!(!result.is_error);
2848 assert_eq!(
2849 app.work_surface.placement,
2850 crate::tui::work_surface::WorkSurfacePlacement::Off
2851 );
2852 let message = result.message.unwrap_or_default();
2853 assert!(message.contains("Rail is off"), "got: {message}");
2854 assert!(
2855 !message.contains("Sidebar is visible"),
2856 "the readout must never claim a dead surface renders: {message}"
2857 );
2858 }
2859
2860 #[test]
2861 fn rail_command_rejects_retired_auto_mode() {
2862 let mut app = create_test_app();
2863
2864 let result = sidebar(&mut app, Some("auto"));
2865
2866 assert!(result.is_error);
2867 assert!(
2868 result
2869 .message
2870 .as_deref()
2871 .unwrap_or_default()
2872 .contains("Usage: /rail")
2873 );
2874 }
2875
2876 #[test]
2877 fn test_mode_yolo_sets_all_flags() {
2878 let mut app = create_test_app();
2879 // Switch to Agent first to guarantee a clean starting state regardless of
2880 // user settings on the host machine.
2881 let _ = mode(&mut app, Some("agent"));
2882 let result = mode(&mut app, Some("yolo"));
2883 // YOLO is invisible Act+Bypass shorthand — user-facing copy says Act.
2884 assert!(result.message.unwrap().contains("Switched to Act mode"));
2885 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
2886 assert!(app.allow_shell);
2887 assert!(app.trust_mode);
2888 assert!(app.yolo);
2889 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
2890 // The deprecated YOLO alias remaps to Agent mode (M6 compat shim).
2891 assert_eq!(app.mode, AppMode::Agent);
2892 }
2893
2894 #[test]
2895 fn test_mode_switch_command_accepts_names_and_numbers() {
2896 let mut app = create_test_app();
2897 let _ = mode(&mut app, Some("agent"));
2898 assert_eq!(app.mode, AppMode::Agent);
2899 let result = mode(&mut app, Some("2"));
2900 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan)));
2901 assert_eq!(app.mode, AppMode::Plan);
2902 let result = mode(&mut app, Some("act"));
2903 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
2904 assert_eq!(app.mode, AppMode::Agent);
2905 let _ = mode(&mut app, Some("plan"));
2906 assert_eq!(app.mode, AppMode::Plan);
2907 let result = mode(&mut app, Some("3"));
2908 assert_eq!(
2909 result.action,
2910 Some(AppAction::ModeChanged(AppMode::Operate))
2911 );
2912 assert_eq!(app.mode, AppMode::Operate);
2913 let result = mode(&mut app, Some("5"));
2914 assert!(result.is_error);
2915 assert_eq!(app.mode, AppMode::Operate);
2916 let result = mode(&mut app, Some("9"));
2917 assert!(result.is_error);
2918 assert_eq!(app.mode, AppMode::Operate);
2919 let result = mode(&mut app, Some("4"));
2920 assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
2921 // "4" still parses as the deprecated YOLO alias, which lands in Agent
2922 // mode with bypass approvals (M6 compat shim).
2923 assert_eq!(app.mode, AppMode::Agent);
2924 assert!(app.yolo);
2925 }
2926
2927 #[test]
2928 fn test_mode_without_arg_opens_picker() {
2929 let mut app = create_test_app();
2930 let result = mode(&mut app, None);
2931 assert!(result.message.is_none());
2932 assert!(matches!(result.action, Some(AppAction::OpenModePicker)));
2933 }
2934
2935 #[test]
2936 fn test_mode_rejects_unknown_value() {
2937 let mut app = create_test_app();
2938 let result = mode(&mut app, Some("fast"));
2939 assert!(result.is_error);
2940 assert!(result.message.unwrap().contains("Usage: /mode"));
2941 }
2942
2943 #[test]
2944 fn test_show_config_defaults_to_native() {
2945 let mut app = create_test_app();
2946 app.session.total_tokens = 1234;
2947 let result = show_config(&mut app, None);
2948 assert!(result.message.is_none());
2949 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
2950 }
2951
2952 #[test]
2953 fn test_show_config_native_opens_legacy_editor() {
2954 let mut app = create_test_app();
2955 let result = show_config(&mut app, Some("native"));
2956 assert!(result.message.is_none());
2957 assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
2958 }
2959
2960 #[test]
2961 fn test_show_settings_loads_from_file() {
2962 let _lock = lock_test_env();
2963 let mut app = create_test_app();
2964 let result = show_settings(&mut app);
2965 // Settings should load (may use defaults if file doesn't exist)
2966 assert!(result.message.is_some());
2967 }
2968
2969 #[test]
2970 fn settings_command_opens_typed_editor_and_preserves_text_mode() {
2971 let _lock = lock_test_env();
2972 let mut app = create_test_app();
2973
2974 let modal = settings_command(&mut app, None);
2975 assert!(modal.message.is_none());
2976 assert!(matches!(modal.action, Some(AppAction::OpenConfigView)));
2977
2978 let text = settings_command(&mut app, Some("text"));
2979 let message = text.message.as_deref().expect("settings diagnostic text");
2980 assert!(message.contains("Settings:"), "{message}");
2981 assert!(message.contains("provider_models:"), "{message}");
2982 assert!(message.contains("Config file:"), "{message}");
2983 assert!(text.action.is_none());
2984 }
2985
2986 #[test]
2987 fn config_model_updates_app_state() {
2988 let mut app = create_test_app();
2989 let _old_model = app.model.clone();
2990 let result = config_command(&mut app, Some("model deepseek-v4-flash"));
2991 assert!(result.message.is_some());
2992 let msg = result.message.unwrap();
2993 assert!(msg.contains("model = deepseek-v4-flash"));
2994 assert_eq!(app.model, "deepseek-v4-flash");
2995 assert!(matches!(
2996 result.action,
2997 Some(AppAction::UpdateCompaction(_))
2998 ));
2999 }
3000
3001 #[test]
3002 fn config_model_rejects_foreign_model_for_direct_provider() {
3003 let mut app = create_test_app();
3004 app.api_provider = ApiProvider::Zai;
3005 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
3006
3007 let result = set_config_value(&mut app, "model", "deepseek-v4-pro", false);
3008
3009 assert!(result.is_error);
3010 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
3011 assert!(result.action.is_none());
3012 let message = result.message.as_deref().expect("rejection message");
3013 assert!(
3014 message.contains("not compatible with provider 'zai'")
3015 || message.contains("not served by direct provider zai"),
3016 "unexpected rejection message: {message}"
3017 );
3018 assert!(message.contains("deepseek-v4-pro"), "{message}");
3019 }
3020
3021 #[test]
3022 fn config_model_auto_preserves_explicit_thinking() {
3023 let mut app = create_test_app();
3024 app.reasoning_effort = ReasoningEffort::Off;
3025 app.reasoning_effort_preference = Some(ReasoningEffort::Off);
3026
3027 let result = config_command(&mut app, Some("model auto"));
3028
3029 assert!(result.message.is_some());
3030 assert!(app.auto_model);
3031 assert_eq!(app.model, "auto");
3032 assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
3033 assert!(
3034 result
3035 .message
3036 .as_deref()
3037 .is_some_and(|message| message.contains("thinking = off"))
3038 );
3039 assert!(app.last_effective_model.is_none());
3040 assert!(app.last_effective_reasoning_effort.is_none());
3041 }
3042
3043 #[test]
3044 fn config_model_auto_releases_implicit_fixed_model_thinking() {
3045 let mut app = create_test_app();
3046 app.reasoning_effort = ReasoningEffort::Max;
3047 app.reasoning_effort_preference = None;
3048
3049 let result = config_command(&mut app, Some("model auto"));
3050
3051 assert!(result.message.is_some());
3052 assert!(app.auto_model);
3053 assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
3054 assert_eq!(app.reasoning_effort_preference, None);
3055 assert!(
3056 result
3057 .message
3058 .as_deref()
3059 .is_some_and(|message| message.contains("thinking = auto"))
3060 );
3061 }
3062
3063 #[test]
3064 fn config_reasoning_effort_applies_while_model_routing_is_auto() {
3065 let mut app = create_test_app();
3066 app.set_model_selection("auto".to_string());
3067 app.reasoning_effort = ReasoningEffort::Auto;
3068 app.reasoning_effort_preference = None;
3069
3070 let result = set_config_value(&mut app, "reasoning_effort", "low", false);
3071
3072 assert!(!result.is_error);
3073 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
3074 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low));
3075 assert!(matches!(
3076 result.action,
3077 Some(AppAction::UpdateCompaction(_))
3078 ));
3079 }
3080
3081 #[test]
3082 fn config_default_model_cannot_replace_a_non_deepseek_live_route() {
3083 let temp_root = env::temp_dir().join(format!(
3084 "codewhale-tui-provider-scoped-default-model-test-{}",
3085 std::process::id()
3086 ));
3087 fs::create_dir_all(&temp_root).unwrap();
3088 let _guard = EnvGuard::new(&temp_root);
3089 let mut app = create_test_app();
3090 app.api_provider = ApiProvider::Zai;
3091 app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
3092 app.auto_model = false;
3093
3094 let session_only = set_config_value(&mut app, "default_model", "deepseek-v4-flash", false);
3095
3096 assert!(session_only.is_error);
3097 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
3098 assert!(session_only.action.is_none());
3099 assert!(
3100 session_only
3101 .message
3102 .as_deref()
3103 .is_some_and(|message| message.contains("DeepSeek startup fallback"))
3104 );
3105
3106 let saved = set_config_value(&mut app, "default_model", "deepseek-v4-flash", true);
3107
3108 assert!(!saved.is_error);
3109 assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
3110 assert!(saved.action.is_none());
3111 assert!(
3112 saved
3113 .message
3114 .as_deref()
3115 .is_some_and(|message| message.contains("active zai/GLM-5.2 is unchanged"))
3116 );
3117 let persisted = Settings::load_persisted().expect("saved settings");
3118 assert_eq!(
3119 persisted.default_model.as_deref(),
3120 Some("deepseek-v4-flash")
3121 );
3122 }
3123
3124 #[test]
3125 fn config_reasoning_effort_uses_codex_provider_labels() {
3126 let temp_root = env::temp_dir().join(format!(
3127 "codewhale-tui-codex-effort-config-test-{}",
3128 std::process::id()
3129 ));
3130 fs::create_dir_all(&temp_root).unwrap();
3131 let _guard = EnvGuard::new(&temp_root);
3132 let mut app = create_test_app();
3133 app.api_provider = ApiProvider::OpenaiCodex;
3134 app.reasoning_effort = ReasoningEffort::High;
3135
3136 let result = set_config_value(&mut app, "reasoning_effort", "off", false);
3137
3138 assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
3139 assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
3140 assert_eq!(
3141 result.message.as_deref(),
3142 Some("reasoning_effort = low (session only, add --save to persist)")
3143 );
3144
3145 let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false);
3146
3147 assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
3148 assert_eq!(
3149 result.message.as_deref(),
3150 Some("reasoning_effort = xhigh (session only, add --save to persist)")
3151 );
3152 }
3153
3154 #[test]
3155 fn config_fancy_animations_keeps_ghostty_frame_cap_without_disabling_motion() {
3156 let temp_root = env::temp_dir().join(format!(
3157 "codewhale-tui-ghostty-fancy-config-test-{}",
3158 std::process::id()
3159 ));
3160 fs::create_dir_all(&temp_root).unwrap();
3161 let _guard = EnvGuard::new(&temp_root);
3162 let prev_term_program = env::var_os("TERM_PROGRAM");
3163 // Safety: test-only environment mutation guarded by EnvGuard's lock.
3164 unsafe {
3165 env::set_var("TERM_PROGRAM", "Ghostty");
3166 }
3167
3168 let mut app = create_test_app();
3169 assert!(app.fancy_animations);
3170 assert!(app.constrained_frame_rate);
3171
3172 let result = set_config_value(&mut app, "fancy_animations", "true", false);
3173
3174 assert!(!result.is_error);
3175 assert!(
3176 app.fancy_animations,
3177 "Ghostty compatibility must cap redraws without disabling motion"
3178 );
3179 assert_eq!(
3180 result.message.as_deref(),
3181 Some("fancy_animations = true (session only, add --save to persist)")
3182 );
3183
3184 // Safety: cleanup under EnvGuard's lock.
3185 unsafe {
3186 match prev_term_program {
3187 Some(v) => env::set_var("TERM_PROGRAM", v),
3188 None => env::remove_var("TERM_PROGRAM"),
3189 }
3190 }
3191 }
3192
3193 #[test]
3194 fn config_model_accepts_future_deepseek_model_id() {
3195 let mut app = create_test_app();
3196 let result = config_command(&mut app, Some("model deepseek-v4"));
3197 assert!(result.message.is_some());
3198 let msg = result.message.unwrap();
3199 assert!(msg.contains("model = deepseek-v4"));
3200 assert_eq!(app.model, "deepseek-v4");
3201 }
3202
3203 #[test]
3204 fn config_model_with_save_flag() {
3205 let temp_root = tempfile::tempdir().expect("isolated settings dir");
3206 let _guard = EnvGuard::new(temp_root.path());
3207 let mut app = create_test_app();
3208 let _result = config_command(&mut app, Some("model deepseek-v4-flash --save"));
3209 assert_eq!(app.model, "deepseek-v4-flash");
3210 }
3211
3212 #[test]
3213 fn config_default_mode_normal_save_reports_normalized_value() {
3214 let nanos = SystemTime::now()
3215 .duration_since(UNIX_EPOCH)
3216 .unwrap()
3217 .as_nanos();
3218 let temp_root = env::temp_dir().join(format!(
3219 "codewhale-tui-default-mode-test-{}-{}",
3220 std::process::id(),
3221 nanos
3222 ));
3223 fs::create_dir_all(&temp_root).unwrap();
3224 let _guard = EnvGuard::new(&temp_root);
3225
3226 let mut app = create_test_app();
3227 let result = config_command(&mut app, Some("default_mode normal --save"));
3228 let msg = result.message.unwrap();
3229 assert_eq!(msg, "default_mode = agent (saved)");
3230 assert_eq!(app.mode, AppMode::Agent);
3231
3232 let settings_path = Settings::path().unwrap();
3233 let saved = fs::read_to_string(settings_path).unwrap();
3234 assert!(saved.contains("default_mode = \"agent\""));
3235 }
3236
3237 #[test]
3238 fn config_command_cost_currency_save_persists_value() {
3239 let nanos = SystemTime::now()
3240 .duration_since(UNIX_EPOCH)
3241 .unwrap()
3242 .as_nanos();
3243 let temp_root = env::temp_dir().join(format!(
3244 "codewhale-tui-cost-currency-test-{}-{}",
3245 std::process::id(),
3246 nanos
3247 ));
3248 fs::create_dir_all(&temp_root).unwrap();
3249 let _guard = EnvGuard::new(&temp_root);
3250
3251 let mut app = create_test_app();
3252 let result = config_command(&mut app, Some("cost_currency cny --save"));
3253 let msg = result.message.unwrap();
3254
3255 assert_eq!(msg, "cost_currency = cny (saved)");
3256 assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
3257
3258 let settings_path = Settings::path().unwrap();
3259 let saved = fs::read_to_string(settings_path).unwrap();
3260 assert!(saved.contains("cost_currency = \"cny\""));
3261 }
3262
3263 #[test]
3264 fn config_command_base_url_save_persists_value() {
3265 let nanos = SystemTime::now()
3266 .duration_since(UNIX_EPOCH)
3267 .unwrap()
3268 .as_nanos();
3269 let temp_root = env::temp_dir().join(format!(
3270 "deepseek-tui-base-url-test-{}-{}",
3271 std::process::id(),
3272 nanos
3273 ));
3274 fs::create_dir_all(&temp_root).unwrap();
3275 let _guard = EnvGuard::new(&temp_root);
3276
3277 let mut app = create_test_app();
3278 let result = config_command(
3279 &mut app,
3280 Some("base_url https://example.internal.local/v1 --save"),
3281 );
3282 let msg = result.message.unwrap();
3283 let saved_path = crate::config_persistence::config_toml_path(None).unwrap();
3284 let saved = fs::read_to_string(&saved_path).unwrap();
3285
3286 assert_eq!(
3287 msg,
3288 format!(
3289 "base_url = https://example.internal.local/v1 (saved to {})",
3290 saved_path.display()
3291 )
3292 );
3293 assert!(saved.contains("base_url = \"https://example.internal.local/v1\""));
3294 }
3295
3296 #[test]
3297 fn config_command_provider_emits_switch_action() {
3298 let mut app = create_test_app();
3299 let result = config_command(&mut app, Some("provider openrouter"));
3300
3301 assert!(!result.is_error);
3302 assert_eq!(result.message.as_deref(), Some("provider = openrouter"));
3303 match result.action {
3304 Some(AppAction::SwitchProvider { provider, model }) => {
3305 assert_eq!(provider, ApiProvider::Openrouter);
3306 assert_eq!(model, None);
3307 }
3308 other => panic!("expected SwitchProvider action, got {other:?}"),
3309 }
3310 }
3311
3312 #[test]
3313 fn config_command_provider_rejects_unknown_provider() {
3314 let mut app = create_test_app();
3315 // "anthropic" became a real provider in #3014; probe with an id that
3316 // stays unknown.
3317 let result = config_command(&mut app, Some("provider not-a-provider"));
3318 assert!(result.is_error);
3319 let msg = result.message.unwrap();
3320 assert!(msg.contains("Unknown provider 'not-a-provider'"));
3321 assert!(msg.contains("openrouter"));
3322 assert!(msg.contains("xiaomi-mimo"));
3323 }
3324
3325 #[test]
3326 fn config_command_allow_shell_enables_agent_shell_session_only() {
3327 let mut app = create_test_app();
3328 assert!(!app.allow_shell);
3329
3330 let result = config_command(&mut app, Some("allow_shell true"));
3331 assert!(!result.is_error);
3332 assert!(app.allow_shell);
3333 let msg = result.message.unwrap();
3334
3335 assert!(msg.contains("allow_shell = true"));
3336 assert!(msg.contains("session only"));
3337 assert!(msg.contains("Act mode"));
3338 assert!(msg.contains("approval gating"));
3339 assert!(msg.contains("next turn"));
3340 assert!(msg.contains("Full Access (Shift+Tab) also enables shell and auto-approves"));
3341 }
3342
3343 #[test]
3344 fn config_command_allow_shell_save_persists_root_boolean() {
3345 let temp_root = tempfile::tempdir().expect("isolated config dir");
3346 let _guard = EnvGuard::new(temp_root.path());
3347
3348 let config_path = temp_root.path().join("custom-config.toml");
3349
3350 let mut app = create_test_app();
3351 app.config_path = Some(config_path.clone());
3352 let result = config_command(&mut app, Some("allow_shell true --save"));
3353 let msg = result.message.unwrap();
3354 let saved = fs::read_to_string(&config_path).unwrap();
3355
3356 assert!(app.allow_shell);
3357 assert_eq!(
3358 msg,
3359 format!(
3360 "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.",
3361 config_path.display()
3362 )
3363 );
3364 assert!(saved.contains("allow_shell = true"));
3365 }
3366
3367 #[test]
3368 fn config_command_allow_shell_rejects_invalid_boolean() {
3369 let mut app = create_test_app();
3370 let result = config_command(&mut app, Some("allow_shell maybe"));
3371 assert!(result.is_error);
3372 assert!(!app.allow_shell);
3373 let msg = result.message.unwrap();
3374 assert!(msg.contains("Failed to parse boolean 'maybe'"));
3375 }
3376
3377 #[test]
3378 fn config_command_cannot_bypass_project_shell_constraint() {
3379 let temp_root = env::temp_dir().join(format!(
3380 "codewhale-project-shell-control-test-{}",
3381 std::process::id()
3382 ));
3383 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
3384 let _guard = EnvGuard::new(&temp_root);
3385 let root_config = temp_root.join(".deepseek").join("config.toml");
3386 fs::write(&root_config, "# user root\n").unwrap();
3387 let workspace = temp_root.join("workspace");
3388 fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
3389 fs::write(
3390 workspace
3391 .join(codewhale_config::CODEWHALE_APP_DIR)
3392 .join("config.toml"),
3393 "allow_shell = false\n",
3394 )
3395 .unwrap();
3396 let mut app = create_test_app();
3397 app.config_path = Some(root_config.clone());
3398 app.workspace = workspace;
3399 app.set_agent_shell_access(false);
3400
3401 let result = config_command(&mut app, Some("allow_shell true --save"));
3402
3403 assert!(result.is_error, "{:?}", result.message);
3404 assert!(!app.allow_shell);
3405 assert!(
3406 result
3407 .message
3408 .as_deref()
3409 .is_some_and(|message| message.contains("project configuration"))
3410 );
3411 assert!(
3412 !fs::read_to_string(root_config)
3413 .unwrap()
3414 .contains("allow_shell")
3415 );
3416 }
3417
3418 #[test]
3419 fn config_command_cannot_bypass_environment_shell_constraint() {
3420 let temp_root = env::temp_dir().join(format!(
3421 "codewhale-env-shell-control-test-{}",
3422 std::process::id()
3423 ));
3424 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
3425 let _guard = EnvGuard::new(&temp_root);
3426 let config_path = temp_root.join(".deepseek").join("config.toml");
3427 fs::write(&config_path, "# root\n").unwrap();
3428 // Safety: EnvGuard holds the process-wide environment lock and restores
3429 // this variable on drop.
3430 unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") };
3431 let config = Config::load(Some(config_path.clone()), None).unwrap();
3432 let mut app = create_test_app_with_config(&config);
3433 app.config_path = Some(config_path);
3434 app.set_agent_shell_access(false);
3435
3436 let result = config_command(&mut app, Some("allow_shell true"));
3437
3438 assert!(result.is_error, "{:?}", result.message);
3439 assert!(!app.allow_shell);
3440 assert!(
3441 result
3442 .message
3443 .as_deref()
3444 .is_some_and(|message| message.contains("DEEPSEEK_ALLOW_SHELL"))
3445 );
3446 }
3447
3448 #[test]
3449 fn config_command_cannot_bypass_project_or_environment_approval() {
3450 let temp_root = env::temp_dir().join(format!(
3451 "codewhale-external-approval-control-test-{}",
3452 std::process::id()
3453 ));
3454 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
3455 let _guard = EnvGuard::new(&temp_root);
3456 let root_config = temp_root.join(".deepseek").join("config.toml");
3457 fs::write(&root_config, "# root\n").unwrap();
3458 let workspace = temp_root.join("workspace");
3459 fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
3460 fs::write(
3461 workspace
3462 .join(codewhale_config::CODEWHALE_APP_DIR)
3463 .join("config.toml"),
3464 "approval_policy = \"never\"\n",
3465 )
3466 .unwrap();
3467 let mut app = create_test_app();
3468 app.config_path = Some(root_config.clone());
3469 app.workspace = workspace;
3470 app.set_agent_approval_posture(ApprovalMode::Never);
3471
3472 let project_result = config_command(&mut app, Some("approval_mode full-access"));
3473 assert!(project_result.is_error, "{:?}", project_result.message);
3474 assert_eq!(app.approval_mode, ApprovalMode::Never);
3475
3476 // Move outside the project and make the environment the controlling
3477 // source for the second half of the regression.
3478 app.workspace = temp_root.join("clean-workspace");
3479 fs::create_dir_all(&app.workspace).unwrap();
3480 // Safety: EnvGuard holds the process-wide environment lock and restores
3481 // this variable on drop.
3482 unsafe { env::set_var("DEEPSEEK_APPROVAL_POLICY", "never") };
3483 let env_result = config_command(&mut app, Some("approval_mode auto"));
3484 assert!(env_result.is_error, "{:?}", env_result.message);
3485 assert_eq!(app.approval_mode, ApprovalMode::Never);
3486 assert!(
3487 env_result
3488 .message
3489 .as_deref()
3490 .is_some_and(|message| message.contains("DEEPSEEK_APPROVAL_POLICY"))
3491 );
3492 }
3493
3494 #[test]
3495 fn config_command_shell_choice_survives_plan_round_trip() {
3496 let mut app = create_test_app();
3497 app.set_agent_approval_posture(ApprovalMode::Bypass);
3498
3499 let result = config_command(&mut app, Some("allow_shell true"));
3500
3501 assert!(!result.is_error, "{:?}", result.message);
3502 app.set_mode(AppMode::Plan);
3503 assert!(!app.allow_shell);
3504 app.set_mode(AppMode::Agent);
3505 assert!(app.allow_shell);
3506 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
3507 }
3508
3509 #[test]
3510 fn config_command_subagents_off_save_persists_and_updates_runtime() {
3511 let temp_root = env::temp_dir().join(format!(
3512 "codewhale-subagents-off-save-test-{}",
3513 std::process::id()
3514 ));
3515 fs::create_dir_all(&temp_root).unwrap();
3516 let config_path = temp_root.join("custom-config.toml");
3517
3518 let mut app = create_test_app();
3519 app.config_path = Some(config_path.clone());
3520 let result = config_command(&mut app, Some("subagents off --save"));
3521 let msg = result.message.unwrap();
3522 let saved = fs::read_to_string(&config_path).unwrap();
3523
3524 assert!(!result.is_error);
3525 assert!(msg.contains("subagents.enabled = false"));
3526 assert!(msg.contains("saved to"));
3527 assert!(saved.contains("[subagents]"));
3528 assert!(saved.contains("enabled = false"));
3529 match result.action {
3530 Some(AppAction::UpdateSubagentRuntimeConfig { enabled, .. }) => {
3531 assert!(!enabled);
3532 }
3533 other => panic!("expected subagent runtime update, got {other:?}"),
3534 }
3535 }
3536
3537 #[test]
3538 fn config_command_subagents_depth_save_clamps_to_ceiling() {
3539 let temp_root = env::temp_dir().join(format!(
3540 "codewhale-subagents-depth-save-test-{}",
3541 std::process::id()
3542 ));
3543 fs::create_dir_all(&temp_root).unwrap();
3544 let config_path = temp_root.join("custom-config.toml");
3545
3546 let mut app = create_test_app();
3547 app.config_path = Some(config_path.clone());
3548 let result = config_command(&mut app, Some("subagents max_depth 99 --save"));
3549 let msg = result.message.unwrap();
3550 let saved = fs::read_to_string(&config_path).unwrap();
3551 let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING;
3552
3553 assert!(!result.is_error);
3554 assert!(msg.contains(&format!("subagents.max_depth = {ceiling}")));
3555 assert!(msg.contains(&format!("clamped from 99 to {ceiling}")));
3556 assert!(saved.contains(&format!("max_depth = {ceiling}")));
3557 match result.action {
3558 Some(AppAction::UpdateSubagentRuntimeConfig {
3559 max_spawn_depth, ..
3560 }) => {
3561 assert_eq!(max_spawn_depth, ceiling);
3562 }
3563 other => panic!("expected subagent runtime update, got {other:?}"),
3564 }
3565 }
3566
3567 #[test]
3568 fn config_command_subagents_status_shows_raw_and_resolved_values() {
3569 let temp_root = env::temp_dir().join(format!(
3570 "codewhale-subagents-status-test-{}",
3571 std::process::id()
3572 ));
3573 fs::create_dir_all(&temp_root).unwrap();
3574 let config_path = temp_root.join("custom-config.toml");
3575 fs::write(
3576 &config_path,
3577 r#"
3578 [subagents]
3579 enabled = true
3580 max_concurrent = 2
3581 max_depth = 0
3582 launch_concurrency = 5
3583 api_timeout_secs = 0
3584 heartbeat_timeout_secs = 1
3585 "#,
3586 )
3587 .unwrap();
3588
3589 let mut app = create_test_app();
3590 app.config_path = Some(config_path);
3591 let result = config_command(&mut app, Some("subagents status"));
3592 let msg = result.message.unwrap();
3593
3594 assert!(!result.is_error);
3595 assert!(msg.contains("Sub-agents: disabled (subagents.max_depth=0)"));
3596 assert!(msg.contains("Active provider: deepseek"));
3597 assert!(
3598 msg.contains("subagents.max_concurrent = 2 (resolved global 2; active provider 2)")
3599 );
3600 assert!(
3601 msg.contains("subagents.launch_concurrency = 5 (resolved global 2; active provider 2)")
3602 );
3603 assert!(
3604 msg.contains(
3605 "subagents.api_timeout_secs = 0 (resolved global 600; active provider 600)"
3606 )
3607 );
3608 assert!(msg.contains(
3609 "subagents.heartbeat_timeout_secs = 1 (resolved global 630; active provider 630)"
3610 ));
3611 assert!(msg.contains("subagents.providers.deepseek = inherits global"));
3612 }
3613
3614 #[test]
3615 fn config_command_audit_lists_editability_and_current_values() {
3616 let temp_root = env::temp_dir().join(format!(
3617 "codewhale-config-audit-test-{}",
3618 std::process::id()
3619 ));
3620 fs::create_dir_all(&temp_root).unwrap();
3621 // Hermetic: the audit reads Settings::load(); without this guard the
3622 // developer's real saved permission_posture leaks in and the
3623 // "(unset)" assertion below becomes machine-dependent.
3624 let _guard = EnvGuard::new(&temp_root);
3625 let config_path = temp_root.join("custom-config.toml");
3626 fs::write(
3627 &config_path,
3628 r#"
3629 base_url = "https://api.from-config.local/v1"
3630 instructions = ["~/global.md"]
3631
3632 [subagents]
3633 enabled = false
3634 max_concurrent = 4
3635 "#,
3636 )
3637 .unwrap();
3638
3639 let mut app = create_test_app();
3640 app.config_path = Some(config_path.clone());
3641 app.approval_mode = ApprovalMode::Never;
3642 app.stream_chunk_timeout_secs = 45;
3643
3644 let result = config_command(&mut app, Some("audit"));
3645 let msg = result.message.unwrap();
3646
3647 assert!(!result.is_error);
3648 assert!(msg.contains("Config editability audit"));
3649 assert!(msg.contains(&format!("Config path: {}", config_path.display())));
3650 assert!(msg.contains("effective_permissions | Never | runtime"));
3651 assert!(msg.contains("permission_posture | (unset) | TUI settings"));
3652 assert!(msg.contains("approval_policy | (unset) | persisted config"));
3653 assert!(msg.contains("stream_chunk_timeout_secs | 45 | runtime+persisted"));
3654 assert!(msg.contains("subagents.enabled | false | runtime+persisted"));
3655 assert!(msg.contains("subagents.max_concurrent | 4 | runtime+persisted"));
3656 assert!(msg.contains("base_url | https://api.from-config.local/v1 | persisted restart"));
3657 assert!(msg.contains("providers.<active>.context_window | (unset) | persisted restart"));
3658 assert!(msg.contains("effective_context_window |"), "{msg}");
3659 assert!(msg.contains("| runtime | /config context_window"), "{msg}");
3660 assert!(msg.contains("instructions | configured | file-only restart"));
3661 assert!(msg.contains("network | unset | file-only"));
3662
3663 app.mode = AppMode::Plan;
3664 let plan_msg = config_command(&mut app, Some("audit"))
3665 .message
3666 .expect("Plan audit message");
3667 assert!(
3668 plan_msg.contains("effective_permissions | Read Only | runtime"),
3669 "{plan_msg}"
3670 );
3671 }
3672
3673 #[test]
3674 fn config_context_window_query_shows_override_and_effective_source() {
3675 let temp_root = env::temp_dir().join(format!(
3676 "codewhale-context-window-query-test-{}",
3677 std::process::id()
3678 ));
3679 fs::create_dir_all(&temp_root).unwrap();
3680 let _guard = EnvGuard::new(&temp_root);
3681 let config_path = temp_root.join("custom-config.toml");
3682 fs::write(
3683 &config_path,
3684 r#"
3685 provider = "moonshot"
3686 [providers.moonshot]
3687 model = "kimi-k3"
3688 context_window = 262144
3689 "#,
3690 )
3691 .unwrap();
3692 let mut app = create_test_app();
3693 app.config_path = Some(config_path);
3694 app.api_provider = ApiProvider::Moonshot;
3695 app.model = "kimi-k3".to_string();
3696 app.active_route_limits = Some(codewhale_config::route::RouteLimits {
3697 context_tokens: Some(262_144),
3698 ..Default::default()
3699 });
3700 app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
3701
3702 let result = config_command(&mut app, Some("context_window"));
3703 let message = result.message.expect("context window message");
3704
3705 assert!(!result.is_error, "{message}");
3706 assert!(
3707 message.contains("262144 (effective 262144 from configured)"),
3708 "{message}"
3709 );
3710 }
3711
3712 #[test]
3713 fn config_command_base_url_without_save_requires_save() {
3714 let _lock = lock_test_env();
3715 let mut app = create_test_app();
3716 let result = config_command(&mut app, Some("base_url https://example.internal.local/v1"));
3717 assert!(result.is_error);
3718 let msg = result.message.unwrap();
3719
3720 assert!(
3721 msg.contains("base_url must be saved with --save"),
3722 "got {msg}"
3723 );
3724 }
3725
3726 #[test]
3727 fn config_command_base_url_reads_current_value_from_config() {
3728 let nanos = SystemTime::now()
3729 .duration_since(UNIX_EPOCH)
3730 .unwrap()
3731 .as_nanos();
3732 let temp_root = env::temp_dir().join(format!(
3733 "deepseek-tui-base-url-show-test-{}-{}",
3734 std::process::id(),
3735 nanos
3736 ));
3737 fs::create_dir_all(&temp_root).unwrap();
3738 let _guard = EnvGuard::new(&temp_root);
3739
3740 let config_path = temp_root.join(".deepseek").join("config.toml");
3741 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
3742 fs::write(
3743 &config_path,
3744 "base_url = \"https://api.from-config.local/v1\"\n",
3745 )
3746 .unwrap();
3747
3748 let mut app = create_test_app();
3749 let result = config_command(&mut app, Some("base_url"));
3750 let msg = result.message.unwrap();
3751
3752 assert_eq!(msg, "base_url = https://api.from-config.local/v1");
3753 }
3754
3755 #[test]
3756 fn config_command_base_url_reads_current_value_from_app_config_path() {
3757 let temp_root = env::temp_dir().join(format!(
3758 "deepseek-tui-base-url-app-config-path-test-{}",
3759 std::process::id()
3760 ));
3761 fs::create_dir_all(&temp_root).unwrap();
3762
3763 let config_path = temp_root.join("custom-config.toml");
3764 fs::write(
3765 &config_path,
3766 "base_url = \"https://api.from-app-path.local/v1\"\n",
3767 )
3768 .unwrap();
3769
3770 let mut app = create_test_app();
3771 app.config_path = Some(config_path.clone());
3772 let result = config_command(&mut app, Some("base_url"));
3773 let msg = result.message.unwrap();
3774
3775 assert_eq!(msg, "base_url = https://api.from-app-path.local/v1");
3776 }
3777
3778 #[test]
3779 fn config_command_base_url_save_persists_to_app_config_path() {
3780 let temp_root = env::temp_dir().join(format!(
3781 "deepseek-tui-base-url-save-app-path-test-{}",
3782 std::process::id()
3783 ));
3784 fs::create_dir_all(&temp_root).unwrap();
3785
3786 let config_path = temp_root.join("custom-config.toml");
3787
3788 let mut app = create_test_app();
3789 app.config_path = Some(config_path.clone());
3790 let result = config_command(
3791 &mut app,
3792 Some("base_url https://example.session.local/v1 --save"),
3793 );
3794 let msg = result.message.unwrap();
3795 let saved = fs::read_to_string(&config_path).unwrap();
3796
3797 assert_eq!(
3798 msg,
3799 format!(
3800 "base_url = https://example.session.local/v1 (saved to {})",
3801 config_path.display()
3802 )
3803 );
3804 assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
3805 }
3806
3807 #[test]
3808 fn config_command_stream_chunk_timeout_session_query_uses_live_value() {
3809 let _lock = lock_test_env();
3810 let mut app = create_test_app();
3811
3812 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90"));
3813 assert!(!result.is_error);
3814 assert_eq!(app.stream_chunk_timeout_secs, 90);
3815 assert!(matches!(
3816 result.action,
3817 Some(AppAction::UpdateStreamChunkTimeout(90))
3818 ));
3819
3820 let query = config_command(&mut app, Some("stream_chunk_timeout_secs"));
3821 assert_eq!(
3822 query.message.as_deref(),
3823 Some("stream_chunk_timeout_secs = 90")
3824 );
3825 }
3826
3827 #[test]
3828 fn config_command_stream_chunk_timeout_save_persists_tui_key() {
3829 let nanos = SystemTime::now()
3830 .duration_since(UNIX_EPOCH)
3831 .unwrap()
3832 .as_nanos();
3833 let temp_root = env::temp_dir().join(format!(
3834 "codewhale-tui-stream-timeout-test-{}-{}",
3835 std::process::id(),
3836 nanos
3837 ));
3838 fs::create_dir_all(&temp_root).unwrap();
3839 let _guard = EnvGuard::new(&temp_root);
3840
3841 let config_path = temp_root.join("custom-config.toml");
3842 let mut app = create_test_app();
3843 app.config_path = Some(config_path.clone());
3844
3845 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save"));
3846 let msg = result.message.unwrap();
3847 let saved = fs::read_to_string(&config_path).unwrap();
3848
3849 assert_eq!(
3850 msg,
3851 format!(
3852 "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)",
3853 config_path.display()
3854 )
3855 );
3856 assert!(saved.contains("[tui]"));
3857 assert!(saved.contains("stream_chunk_timeout_secs = 120"));
3858 assert_eq!(app.stream_chunk_timeout_secs, 120);
3859 assert!(matches!(
3860 result.action,
3861 Some(AppAction::UpdateStreamChunkTimeout(120))
3862 ));
3863 }
3864
3865 #[test]
3866 fn config_command_stream_chunk_timeout_rejects_invalid_input() {
3867 let _lock = lock_test_env();
3868 let mut app = create_test_app();
3869
3870 let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc"));
3871 assert!(text.is_error);
3872 assert!(
3873 text.message
3874 .unwrap()
3875 .contains("stream_chunk_timeout_secs must be a whole number")
3876 );
3877
3878 let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601"));
3879 assert!(high.is_error);
3880 assert!(
3881 high.message
3882 .unwrap()
3883 .contains("stream_chunk_timeout_secs must be 0 or 1..=3600")
3884 );
3885 }
3886
3887 #[test]
3888 fn config_command_stream_chunk_timeout_zero_reports_effective_default() {
3889 let _lock = lock_test_env();
3890 let mut app = create_test_app();
3891
3892 let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0"));
3893
3894 assert!(!result.is_error);
3895 assert_eq!(
3896 app.stream_chunk_timeout_secs,
3897 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
3898 );
3899 assert_eq!(
3900 result.message.as_deref(),
3901 Some(
3902 "stream_chunk_timeout_secs = 0 (default 900) (session only; affects subsequent turns in this session)"
3903 )
3904 );
3905 assert!(matches!(
3906 result.action,
3907 Some(AppAction::UpdateStreamChunkTimeout(
3908 DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
3909 ))
3910 ));
3911 }
3912
3913 #[test]
3914 fn config_command_provider_url_token_plan_persists_provider_base_url() {
3915 let temp_root = env::temp_dir().join(format!(
3916 "codewhale-provider-url-save-app-path-test-{}",
3917 std::process::id()
3918 ));
3919 fs::create_dir_all(&temp_root).unwrap();
3920
3921 let config_path = temp_root.join("custom-config.toml");
3922
3923 let mut app = create_test_app();
3924 app.api_provider = ApiProvider::XiaomiMimo;
3925 app.config_path = Some(config_path.clone());
3926 let result = config_command(&mut app, Some("provider_url token-plan --save"));
3927 let msg = result.message.unwrap();
3928 let saved = fs::read_to_string(&config_path).unwrap();
3929
3930 assert_eq!(
3931 msg,
3932 format!(
3933 "provider_url = {} for xiaomi-mimo (saved to {}; restart required)",
3934 DEFAULT_XIAOMI_MIMO_BASE_URL,
3935 config_path.display()
3936 )
3937 );
3938 assert!(saved.contains("[providers.xiaomi_mimo]"));
3939 assert!(saved.contains(&format!("base_url = \"{DEFAULT_XIAOMI_MIMO_BASE_URL}\"")));
3940 }
3941
3942 #[test]
3943 fn config_command_provider_url_without_save_requires_save() {
3944 let _lock = lock_test_env();
3945 let mut app = create_test_app();
3946 app.api_provider = ApiProvider::XiaomiMimo;
3947 let result = config_command(&mut app, Some("provider_url token-plan"));
3948 assert!(result.is_error);
3949 let msg = result.message.unwrap();
3950
3951 assert!(
3952 msg.contains("provider_url must be saved with --save"),
3953 "got {msg}"
3954 );
3955 }
3956
3957 #[test]
3958 fn theme_command_accepts_grayscale_arg() {
3959 let nanos = SystemTime::now()
3960 .duration_since(UNIX_EPOCH)
3961 .unwrap()
3962 .as_nanos();
3963 let temp_root = env::temp_dir().join(format!(
3964 "codewhale-tui-theme-command-test-{}-{}",
3965 std::process::id(),
3966 nanos
3967 ));
3968 fs::create_dir_all(&temp_root).unwrap();
3969 let _guard = EnvGuard::new(&temp_root);
3970
3971 let mut app = create_test_app();
3972 let result = theme(&mut app, Some("grayscale"));
3973
3974 assert_eq!(result.message.unwrap(), "theme = grayscale (saved)");
3975 assert_eq!(app.theme_id, crate::palette::ThemeId::Grayscale);
3976 assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale);
3977 assert!(app.needs_redraw);
3978 }
3979
3980 #[test]
3981 fn explicit_default_background_override_survives_theme_preview() {
3982 let temp_root = env::temp_dir().join(format!(
3983 "codewhale-tui-background-override-test-{}-{}",
3984 std::process::id(),
3985 SystemTime::now()
3986 .duration_since(UNIX_EPOCH)
3987 .expect("clock")
3988 .as_nanos()
3989 ));
3990 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
3991 let _guard = EnvGuard::new(&temp_root);
3992 fs::write(
3993 temp_root.join(".deepseek").join("settings.toml"),
3994 "theme = \"solarized-light\"\nbackground_color = \"#fdf6e3\"\n",
3995 )
3996 .expect("seed settings");
3997
3998 let mut app = create_test_app();
3999 let explicit_base3 = ratatui::style::Color::Rgb(0xfd, 0xf6, 0xe3);
4000 assert_eq!(app.background_color_override, Some(explicit_base3));
4001
4002 let result = set_config_value(&mut app, "theme", "dark", false);
4003
4004 assert!(!result.is_error, "{:?}", result.message);
4005 assert_eq!(app.theme_id, crate::palette::ThemeId::Whale);
4006 assert_eq!(app.background_color_override, Some(explicit_base3));
4007 assert_eq!(app.ui_theme.surface_bg, explicit_base3);
4008 assert!(
4009 crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(),
4010 "the explicit surface must retain ombre when previewing another theme"
4011 );
4012 }
4013
4014 #[test]
4015 fn session_only_background_override_survives_theme_preview() {
4016 let temp_root = env::temp_dir().join(format!(
4017 "codewhale-tui-session-background-test-{}-{}",
4018 std::process::id(),
4019 SystemTime::now()
4020 .duration_since(UNIX_EPOCH)
4021 .expect("clock")
4022 .as_nanos()
4023 ));
4024 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
4025 let _guard = EnvGuard::new(&temp_root);
4026 fs::write(
4027 temp_root.join(".deepseek").join("settings.toml"),
4028 "theme = \"solarized-light\"\n",
4029 )
4030 .expect("seed settings");
4031
4032 let mut app = create_test_app();
4033 let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26);
4034 let background = set_config_value(&mut app, "background_color", "#1a1b26", false);
4035 assert!(!background.is_error, "{:?}", background.message);
4036 assert_eq!(app.background_color_override, Some(custom));
4037
4038 let preview = set_config_value(&mut app, "theme", "dark", false);
4039 assert!(!preview.is_error, "{:?}", preview.message);
4040 assert_eq!(app.background_color_override, Some(custom));
4041 assert_eq!(app.ui_theme.surface_bg, custom);
4042
4043 let solarized_preview = set_config_value(&mut app, "theme", "solarized-light", false);
4044 assert!(
4045 !solarized_preview.is_error,
4046 "{:?}",
4047 solarized_preview.message
4048 );
4049 assert_eq!(app.background_color_override, Some(custom));
4050 assert_eq!(app.ui_theme.surface_bg, custom);
4051 assert!(crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some());
4052
4053 let saved_theme = set_config_value(&mut app, "theme", "dark", true);
4054 assert!(!saved_theme.is_error, "{:?}", saved_theme.message);
4055 assert_eq!(app.background_color_override, Some(custom));
4056 assert_eq!(app.ui_theme.surface_bg, custom);
4057 let persisted = Settings::load_persisted().expect("persisted settings");
4058 assert_eq!(persisted.theme, "dark");
4059 assert_eq!(
4060 persisted.background_color, None,
4061 "saving a theme must not persist the session-only background"
4062 );
4063 }
4064
4065 #[test]
4066 fn set_theme_save_updates_live_app_and_persists() {
4067 let nanos = SystemTime::now()
4068 .duration_since(UNIX_EPOCH)
4069 .unwrap()
4070 .as_nanos();
4071 let temp_root = env::temp_dir().join(format!(
4072 "codewhale-tui-theme-save-test-{}-{}",
4073 std::process::id(),
4074 nanos
4075 ));
4076 fs::create_dir_all(&temp_root).unwrap();
4077 let _guard = EnvGuard::new(&temp_root);
4078
4079 let mut app = create_test_app();
4080 let result = config_command(&mut app, Some("theme grayscale --save"));
4081 let msg = result.message.unwrap();
4082
4083 assert_eq!(msg, "theme = grayscale (saved)");
4084 assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale);
4085
4086 let settings_path = Settings::path().unwrap();
4087 let saved = fs::read_to_string(settings_path).unwrap();
4088 assert!(saved.contains("theme = \"grayscale\""));
4089 }
4090
4091 #[test]
4092 fn unrelated_save_does_not_persist_no_animations_runtime_overlay() {
4093 let temp_root = env::temp_dir().join(format!(
4094 "codewhale-no-animations-save-test-{}-{}",
4095 std::process::id(),
4096 SystemTime::now()
4097 .duration_since(UNIX_EPOCH)
4098 .expect("clock")
4099 .as_nanos()
4100 ));
4101 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
4102 let _guard = EnvGuard::new(&temp_root);
4103 fs::write(
4104 temp_root.join(".deepseek").join("settings.toml"),
4105 "low_motion = false\nfancy_animations = true\ntheme = \"system\"\n",
4106 )
4107 .expect("seed settings");
4108 // Safety: test-only environment mutation is serialized by EnvGuard.
4109 unsafe {
4110 env::set_var("NO_ANIMATIONS", "1");
4111 }
4112
4113 let mut app = create_test_app();
4114 assert!(app.low_motion, "runtime overlay should reduce motion");
4115 assert!(
4116 !app.fancy_animations,
4117 "runtime overlay should disable ocean animations"
4118 );
4119
4120 let result = set_config_value(&mut app, "theme", "grayscale", true);
4121 assert!(!result.is_error, "{:?}", result.message);
4122 let saved = Settings::load_persisted().expect("persisted settings");
4123 assert_eq!(saved.theme, "grayscale");
4124 assert!(
4125 !saved.low_motion,
4126 "NO_ANIMATIONS must not become a saved preference"
4127 );
4128 assert!(
4129 saved.fancy_animations,
4130 "NO_ANIMATIONS must not overwrite the saved animation preference"
4131 );
4132 assert!(app.low_motion);
4133 assert!(!app.fancy_animations);
4134 }
4135
4136 #[test]
4137 fn preset_save_does_not_persist_runtime_environment_overlays() {
4138 let temp_root = env::temp_dir().join(format!(
4139 "codewhale-preset-env-overlay-test-{}-{}",
4140 std::process::id(),
4141 SystemTime::now()
4142 .duration_since(UNIX_EPOCH)
4143 .expect("clock")
4144 .as_nanos()
4145 ));
4146 fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
4147 let _guard = EnvGuard::new(&temp_root);
4148 fs::write(
4149 temp_root.join(".deepseek").join("settings.toml"),
4150 "low_motion = false\nfancy_animations = true\nsynchronized_output = \"auto\"\n",
4151 )
4152 .expect("seed settings");
4153 // NO_ANIMATIONS exercises the reported path. Ptyxis supplies an
4154 // unrelated effective-only field, making an accidental
4155 // apply_env_overrides()+save observable even though the calm preset
4156 // intentionally selects reduced motion itself.
4157 unsafe {
4158 env::set_var("NO_ANIMATIONS", "1");
4159 env::set_var("PTYXIS_VERSION", "50.0");
4160 }
4161
4162 let mut app = create_test_app();
4163 let result = config_command(&mut app, Some("preset calm --save"));
4164 assert!(!result.is_error, "{:?}", result.message);
4165
4166 let saved = Settings::load_persisted().expect("persisted settings");
4167 assert!(saved.low_motion, "calm preset should save reduced motion");
4168 assert!(
4169 !saved.fancy_animations,
4170 "calm preset should save static ocean chrome"
4171 );
4172 assert_eq!(
4173 saved.synchronized_output, "auto",
4174 "Ptyxis runtime override must not leak into a preset save"
4175 );
4176 }
4177
4178 #[test]
4179 fn config_approval_mode_valid_values() {
4180 let dir = tempfile::tempdir().expect("isolated config dir");
4181 let mut app = create_test_app();
4182 app.config_path = Some(dir.path().join("config.toml"));
4183 // Test auto
4184 let result = config_command(&mut app, Some("approval_mode auto"));
4185 assert!(result.message.is_some());
4186 assert_eq!(app.approval_mode, ApprovalMode::Auto);
4187
4188 // Test suggest
4189 let result = config_command(&mut app, Some("approval_mode suggest"));
4190 assert!(result.message.is_some());
4191 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
4192
4193 // Test never
4194 let result = config_command(&mut app, Some("approval_mode never"));
4195 assert!(result.message.is_some());
4196 assert_eq!(app.approval_mode, ApprovalMode::Never);
4197 }
4198
4199 #[test]
4200 fn config_approval_mode_save_persists_top_level_policy() {
4201 let temp_root = env::temp_dir().join(format!(
4202 "codewhale-approval-policy-save-test-{}",
4203 std::process::id()
4204 ));
4205 fs::create_dir_all(&temp_root).unwrap();
4206 let _guard = EnvGuard::new(&temp_root);
4207 let config_path = temp_root.join("custom-config.toml");
4208
4209 let mut app = create_test_app();
4210 app.config_path = Some(config_path.clone());
4211 let result = config_command(&mut app, Some("approval_mode suggest --save"));
4212 let msg = result.message.unwrap();
4213 let saved = fs::read_to_string(&config_path).unwrap();
4214
4215 assert!(!result.is_error);
4216 assert_eq!(app.approval_mode, ApprovalMode::Suggest);
4217 assert_eq!(
4218 msg,
4219 format!(
4220 "approval_mode = Ask (saved to {} as approval_policy = \"on-request\")",
4221 config_path.display()
4222 )
4223 );
4224 assert!(saved.contains("approval_policy = \"on-request\""));
4225
4226 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
4227 assert_eq!(loaded.approval_policy.as_deref(), Some("on-request"));
4228
4229 let mut restarted = create_test_app_with_config(&loaded);
4230 restarted.config_path = Some(config_path.clone());
4231 assert!(restarted.approval_policy_locked());
4232 assert!(!restarted.approval_policy_requirements_managed());
4233 let changed = config_command(&mut restarted, Some("approval_mode auto --save"));
4234 assert!(!changed.is_error, "{:?}", changed.message);
4235 assert_eq!(
4236 changed.action,
4237 Some(AppAction::ApprovalPolicyPersisted {
4238 policy: Some("auto".to_string())
4239 })
4240 );
4241 assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
4242 let reloaded = Config::load(Some(config_path), None).unwrap();
4243 assert_eq!(reloaded.approval_policy.as_deref(), Some("auto"));
4244 }
4245
4246 #[test]
4247 fn config_approval_policy_can_return_to_saved_tui_permission_default() {
4248 let temp_root = env::temp_dir().join(format!(
4249 "codewhale-approval-policy-tui-default-test-{}",
4250 std::process::id()
4251 ));
4252 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
4253 let _guard = EnvGuard::new(&temp_root);
4254 let config_path = temp_root.join("custom-config.toml");
4255 fs::write(&config_path, "# keep\napproval_policy = \"auto\"\n").unwrap();
4256 fs::write(
4257 temp_root.join(".deepseek").join("settings.toml"),
4258 "permission_posture = \"full-access\"\n",
4259 )
4260 .unwrap();
4261 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
4262 let mut app = create_test_app_with_config(&loaded);
4263 app.config_path = Some(config_path.clone());
4264
4265 let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true);
4266
4267 assert!(!result.is_error, "{:?}", result.message);
4268 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
4269 assert!(!app.approval_policy_locked());
4270 assert_eq!(
4271 result.action,
4272 Some(AppAction::ApprovalPolicyPersisted { policy: None })
4273 );
4274 let saved = fs::read_to_string(config_path).unwrap();
4275 assert!(saved.contains("# keep"));
4276 assert!(!saved.contains("approval_policy"));
4277 }
4278
4279 #[test]
4280 fn config_approval_policy_full_access_adopts_tui_posture_and_releases_root_override() {
4281 let temp_root = env::temp_dir().join(format!(
4282 "codewhale-approval-policy-full-access-test-{}",
4283 std::process::id()
4284 ));
4285 fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
4286 let _guard = EnvGuard::new(&temp_root);
4287 let config_path = temp_root.join("custom-config.toml");
4288 fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n").unwrap();
4289 fs::write(
4290 temp_root.join(".deepseek").join("settings.toml"),
4291 "permission_posture = \"ask\"\n",
4292 )
4293 .unwrap();
4294 let loaded = Config::load(Some(config_path.clone()), None).unwrap();
4295 let mut app = create_test_app_with_config(&loaded);
4296 app.config_path = Some(config_path.clone());
4297 // The production constructor receives the path up front and marks a
4298 // user-owned root policy editable. This focused fixture attaches the
4299 // path after construction, so mirror that resolved ownership here.
4300 app.mark_approval_policy_locked();
4301 assert!(app.approval_policy_locked());
4302
4303 let result = set_config_value(&mut app, "approval_policy", "full-access", true);
4304
4305 assert!(!result.is_error, "{:?}", result.message);
4306 assert_eq!(app.approval_mode, ApprovalMode::Bypass);
4307 assert!(!app.approval_policy_locked());
4308 assert_eq!(
4309 result.action,
4310 Some(AppAction::ApprovalPolicyPersisted { policy: None })
4311 );
4312 let saved_config = fs::read_to_string(config_path).unwrap();
4313 assert!(saved_config.contains("# keep"));
4314 assert!(!saved_config.contains("approval_policy"));
4315 let saved_settings = Settings::load_persisted().expect("saved TUI settings");
4316 assert_eq!(
4317 saved_settings.permission_posture.as_deref(),
4318 Some("full-access")
4319 );
4320 }
4321
4322 #[test]
4323 fn config_approval_mode_invalid_value() {
4324 let dir = tempfile::tempdir().expect("isolated config dir");
4325 let mut app = create_test_app();
4326 app.config_path = Some(dir.path().join("config.toml"));
4327 let result = config_command(&mut app, Some("approval_mode invalid"));
4328 assert!(result.message.is_some());
4329 let msg = result.message.unwrap();
4330 assert!(msg.contains("Invalid approval_mode"));
4331 }
4332
4333 #[test]
4334 fn config_without_save_flag() {
4335 let _lock = lock_test_env();
4336 let mut app = create_test_app();
4337 let result = config_command(&mut app, Some("auto_compact true"));
4338 assert!(result.message.is_some());
4339 let msg = result.message.unwrap();
4340 assert!(msg.contains("(session only"));
4341 }
4342
4343 #[test]
4344 fn config_threshold_enables_and_updates_live_auto_compaction() {
4345 let _lock = lock_test_env();
4346 let mut app = create_test_app();
4347 app.auto_compact = false;
4348 app.auto_compact_user_configured = false;
4349
4350 let result = config_command(&mut app, Some("auto_compact_threshold_percent 65"));
4351
4352 assert!(!result.is_error, "{:?}", result.message);
4353 assert!(app.auto_compact);
4354 assert!(app.auto_compact_user_configured);
4355 assert_eq!(app.auto_compact_threshold_percent, 65.0);
4356 assert_eq!(
4357 app.compact_threshold,
4358 crate::route_budget::compaction_threshold_for_route_at_percent(
4359 app.api_provider,
4360 app.effective_model_for_budget(),
4361 app.active_route_limits,
4362 65.0,
4363 )
4364 );
4365 assert!(matches!(
4366 result.action,
4367 Some(AppAction::UpdateCompaction(_))
4368 ));
4369 }
4370
4371 #[test]
4372 fn config_composer_border_updates_live_app() {
4373 let _lock = lock_test_env();
4374 let mut app = create_test_app();
4375 app.composer_border = true;
4376
4377 let result = config_command(&mut app, Some("composer_border false"));
4378
4379 assert!(result.message.is_some());
4380 assert!(!app.composer_border);
4381 assert!(app.needs_redraw);
4382 }
4383
4384 #[test]
4385 fn test_trust_on_enables_flag() {
4386 let mut app = create_test_app();
4387 // Normalize trust state regardless of user settings on the host machine.
4388 app.trust_mode = false;
4389 let result = trust(&mut app, Some("on"));
4390 let msg = result.message.expect("message");
4391 assert!(msg.contains("Workspace trust mode enabled"));
4392 assert!(app.trust_mode);
4393 }
4394
4395 #[test]
4396 fn test_trust_status_default_lists_state() {
4397 let mut app = create_test_app();
4398 let result = trust(&mut app, None);
4399 let msg = result.message.expect("status message");
4400 assert!(msg.contains("Workspace trust mode"));
4401 }
4402
4403 #[test]
4404 fn test_trust_add_requires_path() {
4405 let mut app = create_test_app();
4406 let result = trust(&mut app, Some("add"));
4407 let msg = result.message.expect("error message");
4408 assert!(msg.starts_with("Error:"), "got {msg:?}");
4409 }
4410
4411 #[test]
4412 fn test_logout_clears_api_key_state() {
4413 let nanos = SystemTime::now()
4414 .duration_since(UNIX_EPOCH)
4415 .unwrap()
4416 .as_nanos();
4417 let temp_root = env::temp_dir().join(format!(
4418 "codewhale-tui-logout-test-{}-{}",
4419 std::process::id(),
4420 nanos
4421 ));
4422 fs::create_dir_all(&temp_root).unwrap();
4423 let _guard = EnvGuard::new(&temp_root);
4424
4425 let config_path = temp_root.join(".deepseek").join("config.toml");
4426 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
4427 fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
4428
4429 let mut app = create_test_app();
4430 let result = logout(&mut app);
4431 assert!(result.message.is_some());
4432 assert_eq!(app.onboarding, OnboardingState::Provider);
4433 assert!(app.onboarding_needs_api_key);
4434 assert!(app.onboarding_missing_key_recovery);
4435 assert_eq!(result.action, Some(AppAction::OpenProviderPicker));
4436
4437 let updated = fs::read_to_string(config_path).unwrap();
4438 assert!(!updated.contains("api_key"));
4439 }
4440
4441 #[test]
4442 fn logout_clears_only_exact_named_custom_provider_key() {
4443 let nanos = SystemTime::now()
4444 .duration_since(UNIX_EPOCH)
4445 .unwrap()
4446 .as_nanos();
4447 let temp_root = env::temp_dir().join(format!(
4448 "codewhale-custom-logout-test-{}-{}",
4449 std::process::id(),
4450 nanos
4451 ));
4452 fs::create_dir_all(&temp_root).unwrap();
4453 let _guard = EnvGuard::new(&temp_root);
4454 let config_path = temp_root.join(".deepseek").join("config.toml");
4455 fs::create_dir_all(config_path.parent().unwrap()).unwrap();
4456 fs::write(
4457 &config_path,
4458 "[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n",
4459 )
4460 .unwrap();
4461 let mut app = create_test_app();
4462 app.set_provider_identity(ApiProvider::Custom, "custom-a");
4463
4464 let result = logout(&mut app);
4465
4466 assert!(result.message.is_some());
4467 let updated = fs::read_to_string(config_path).unwrap();
4468 assert!(!updated.contains("a-key"), "{updated}");
4469 assert!(updated.contains("b-key"), "{updated}");
4470 }
4471
4472 #[test]
4473 fn named_custom_provider_url_write_fails_closed() {
4474 let mut app = create_test_app();
4475 app.set_provider_identity(ApiProvider::Custom, "custom-a");
4476
4477 let result = config_command(
4478 &mut app,
4479 Some("provider_url http://127.0.0.1:18181/v1 --save"),
4480 );
4481 let message = result.message.expect("error message");
4482
4483 assert!(
4484 message.contains("named [providers.<name>] table"),
4485 "{message}"
4486 );
4487 }
4488 }
4489
4489 lines RUST