use super::CommandResult;
use crate::config::{
ApiProvider, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, DEFAULT_SUBAGENT_API_TIMEOUT_SECS,
DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, DEFAULT_XIAOMI_MIMO_BASE_URL,
MAX_STREAM_CHUNK_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS,
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENTS, MIN_STREAM_CHUNK_TIMEOUT_SECS,
MIN_SUBAGENT_API_TIMEOUT_SECS, MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, SubagentsConfig,
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key,
normalize_model_name_for_provider, validate_route,
};
use crate::config_persistence::{
persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key,
persist_subagents_bool_key, persist_subagents_integer_key, persist_tui_integer_key,
persist_unset_root_key,
};
use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::localization::{MessageId, resolve_locale};
use crate::settings::Settings;
use crate::tui::app::{
App, AppAction, AppMode, OnboardingState, ReasoningEffort, SettingSelection, SidebarFocus,
VimMode,
};
use crate::tui::approval::ApprovalMode;
use crate::tui::ui::{SidebarRenderState, sidebar_render_state};
use anyhow::Result;
use std::path::{Path, PathBuf};
pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult {
let mode = match parse_mode(arg) {
Ok(mode) => mode,
Err(err) => return CommandResult::error(err),
};
if mode == ConfigUiMode::Web && !cfg!(feature = "web") {
return CommandResult::error(
"This build does not include the web config UI. Rebuild with the `web` feature.",
);
}
let action = match mode {
ConfigUiMode::Native => AppAction::OpenConfigView,
ConfigUiMode::Tui | ConfigUiMode::Web => AppAction::OpenConfigEditor(mode),
};
CommandResult::action(action)
}
pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
if raw.is_empty() {
return show_config(app, None);
}
if matches!(
raw.to_ascii_lowercase().as_str(),
"audit" | "editability" | "editable" | "status"
) {
return config_editability_audit(app);
}
let mut raw_words = raw.splitn(2, char::is_whitespace);
let first_word = raw_words.next();
if first_word.is_some_and(is_ask_rules_config_token) {
let rest = raw_words.next().unwrap_or("").trim();
return super::permissions::permissions_command(app, Some(rest));
}
if first_word.is_some_and(|token| token.eq_ignore_ascii_case("subagents")) {
let rest = raw_words.next().unwrap_or("").trim();
return subagents_config_command(app, rest);
}
if first_word.is_some_and(|token| token.eq_ignore_ascii_case("preset")) {
let rest = raw_words.next().unwrap_or("").trim();
return config_preset_command(app, rest);
}
let parts: Vec<&str> = raw.splitn(2, ' ').collect();
if parts.len() == 1 {
let token = parts[0];
if matches!(
token.to_ascii_lowercase().as_str(),
"tui" | "web" | "native"
) {
return show_config(app, Some(token));
}
show_single_setting(app, token)
} else {
let raw_value = parts[1];
let persist = raw_value.ends_with(" --save") || raw_value.ends_with(" -s");
let value = if persist {
raw_value
.strip_suffix(" --save")
.or_else(|| raw_value.strip_suffix(" -s"))
.unwrap_or(raw_value)
} else {
raw_value
};
set_config_value(app, parts[0], value, persist)
}
}
fn preset_preflight(app: &App, fields: &[(&str, &str)]) -> Option<String> {
for (key, value) in fields {
if app.is_loading
&& let Some(subject) = live_route_setting_subject(&key.to_lowercase())
{
return Some(app.setting_locked_message(subject));
}
if let Err(e) = Settings::default().set(key, value) {
return Some(format!("Failed to apply preset field {key}={value}: {e}"));
}
}
None
}
fn config_preset_command(app: &mut App, rest: &str) -> CommandResult {
let tokens: Vec<&str> = rest.split_whitespace().collect();
let persist = matches!(tokens.last(), Some(&"--save") | Some(&"-s"));
let name = tokens.first().copied().unwrap_or("");
if name.is_empty() || name.starts_with('-') {
return CommandResult::message(
"Usage: /config preset <name> [--save]. Available presets: calm.",
);
}
let Some(fields) = crate::settings::preset_fields(name) else {
return CommandResult::error(format!("Unknown preset '{name}'. Available presets: calm."));
};
if let Some(refusal) = preset_preflight(app, fields) {
return CommandResult::error(refusal);
}
if persist {
if let Err(e) = Settings::transact(|settings| settings.apply_preset(name)) {
return CommandResult::error(format!("Failed to save settings: {e}"));
}
}
let mut applied = Vec::with_capacity(fields.len());
for (key, value) in fields {
let result = set_config_value(app, key, value, false);
if result.is_error {
let message = result
.message
.unwrap_or_else(|| "unknown apply error".to_string());
return CommandResult::error(format!(
"Failed to apply preset field {key}={value}: {message}"
));
}
applied.push(format!("{key}={value}"));
}
let suffix = if persist {
" (saved)"
} else {
" (session only — add --save to persist)"
};
CommandResult::message(format!(
"Applied '{name}' transcript preset{suffix}: {}. Thinking stays visible and tool runs stay expandable.",
applied.join(", ")
))
}
fn config_context_window_override(app: &App) -> Option<u32> {
let mut config = Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?;
config.provider = Some(app.provider_identity_for_persistence().to_string());
config.context_window_for_provider_config(app.api_provider)
}
fn show_single_setting(app: &App, key: &str) -> CommandResult {
let key = key.to_lowercase();
if let Some(subagent_key) = key.strip_prefix("subagents.") {
return show_subagents_setting(app, subagent_key);
}
fn locale_display(l: crate::localization::Locale) -> &'static str {
match l {
crate::localization::Locale::En => "en",
crate::localization::Locale::ZhHans => "zh-Hans",
crate::localization::Locale::ZhHant => "zh-Hant",
crate::localization::Locale::Ja => "ja",
crate::localization::Locale::PtBr => "pt-BR",
crate::localization::Locale::Es419 => "es-419",
crate::localization::Locale::Vi => "vi",
crate::localization::Locale::Ko => "ko",
crate::localization::Locale::Ca => "ca",
crate::localization::Locale::De => "de",
crate::localization::Locale::Fr => "fr",
crate::localization::Locale::Id => "id",
crate::localization::Locale::Hi => "hi",
crate::localization::Locale::Ru => "ru",
crate::localization::Locale::Uk => "uk",
}
}
fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str {
match d {
crate::tui::app::ComposerDensity::Compact => "compact",
crate::tui::app::ComposerDensity::Comfortable => "comfortable",
crate::tui::app::ComposerDensity::Spacious => "spacious",
}
}
fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str {
match s {
crate::tui::app::TranscriptSpacing::Compact => "compact",
crate::tui::app::TranscriptSpacing::Comfortable => "comfortable",
crate::tui::app::TranscriptSpacing::Spacious => "spacious",
}
}
let value = match key.as_str() {
"model" => {
if app.auto_model {
let mut label = "auto (auto-select model per turn)".to_string();
if let Some(effective) = app.last_effective_model.as_deref()
&& effective != "auto"
{
label.push_str(&format!("; last: {effective}"));
}
Some(label)
} else {
Some(app.model.clone())
}
}
"provider" => Some(app.provider_identity_for_persistence().to_string()),
"approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()),
"allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()),
"base_url" => {
let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
{
Ok(config) => config,
Err(err) => {
return CommandResult::error(format!("Failed to load config: {err}"));
}
};
Some(config.deepseek_base_url())
}
"provider_url" | "provider_base_url" | "endpoint" => {
let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref())
{
Ok(mut config) => {
config.provider = Some(app.provider_identity_for_persistence().to_string());
config
}
Err(err) => {
return CommandResult::error(format!("Failed to load config: {err}"));
}
};
Some(config.deepseek_base_url())
}
"context_window" | "context_window_tokens" => Some(format!(
"{} (effective {} from {})",
config_context_window_override(app)
.map_or_else(|| "not set".to_string(), |tokens| tokens.to_string()),
crate::route_budget::route_context_window_tokens(
app.api_provider,
app.effective_model_for_budget(),
app.active_route_limits,
),
app.active_context_window_source.label(),
)),
"stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()),
"locale" | "language" => Some(locale_display(app.ui_locale).to_string()),
"theme" | "ui_theme" => {
Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string())
}
"background_color" | "background" | "bg" => {
crate::palette::hex_rgb_string(app.ui_theme.surface_bg)
.or_else(|| Some("(default)".to_string()))
}
"auto_compact" | "compact" => {
Some(if app.auto_compact { "true" } else { "false" }.to_string())
}
"calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()),
"low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()),
"fancy_animations" | "fancy" | "animations" => Some(
if app.fancy_animations {
"true"
} else {
"false"
}
.to_string(),
),
"bracketed_paste" | "paste" => Some(
if app.use_bracketed_paste {
"true"
} else {
"false"
}
.to_string(),
),
"paste_burst_detection" | "paste_burst" => Some(
if app.use_paste_burst_detection {
"true"
} else {
"false"
}
.to_string(),
),
"show_thinking" | "thinking" => {
Some(if app.show_thinking { "true" } else { "false" }.to_string())
}
"thinking_default_expanded" | "thinking_expanded" => Some(
if app.thinking_default_expanded {
"true"
} else {
"false"
}
.to_string(),
),
"thinking_highlight" | "reasoning_highlight" => Some(
if app.thinking_highlight {
"true"
} else {
"false"
}
.to_string(),
),
"show_tool_details" | "tool_details" => Some(
if app.show_tool_details {
"true"
} else {
"false"
}
.to_string(),
),
"inline_diffs" | "inline_diff" | "diffs" => {
Some(app.inline_diff_mode.as_setting().to_string())
}
"mode" | "default_mode" => Some(app.mode.as_setting().to_string()),
"max_history" | "history" => Some(app.max_input_history.to_string()),
"sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()),
"sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()),
"work_surface_placement" | "work_surface" | "work_rail" => {
Some(app.work_surface.placement.as_setting().to_string())
}
"work_surface_top_height" | "work_top_height" => {
Some(app.work_surface.top_height.to_string())
}
"work_surface_side_width" | "work_side_width" => {
Some(app.work_surface.side_width.to_string())
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
Some(app.tool_collapse_mode.as_setting().to_string())
}
"context_panel" | "context" | "session_panel" => {
Some(if app.context_panel { "true" } else { "false" }.to_string())
}
"sessions_rail" | "sessions_panel" | "session_rail" => {
Some(if app.sessions_rail { "true" } else { "false" }.to_string())
}
"session_auto_resume" | "auto_resume" => Some(
if crate::settings::Settings::load_persisted()
.map(|settings| settings.session_auto_resume)
.unwrap_or(false)
{
"true"
} else {
"false"
}
.to_string(),
),
"composer_density" | "composer" => Some(density_display(app.composer_density).to_string()),
"composer_border" | "border" => {
Some(if app.composer_border { "true" } else { "false" }.to_string())
}
"composer_vim_mode" | "vim_mode" | "vim" => Some(
if app.composer.vim_enabled {
"vim"
} else {
"normal"
}
.to_string(),
),
"transcript_spacing" | "spacing" => {
Some(spacing_display(app.transcript_spacing).to_string())
}
"status_indicator" | "indicator" => Some(app.status_indicator.clone()),
"synchronized_output" | "sync_output" | "sync" => Some(
if app.synchronized_output_enabled {
"on"
} else {
"off"
}
.to_string(),
),
"cost_currency" | "currency" => Some(
match app.cost_currency {
crate::pricing::CostCurrency::Usd => "usd",
crate::pricing::CostCurrency::Cny => "cny",
}
.to_string(),
),
"default_model" => Settings::load().ok().map(|settings| {
settings
.default_model
.unwrap_or_else(|| "(default)".to_string())
}),
"reasoning_effort" | "effort" => Some(
app.reasoning_effort
.as_setting_for_provider(app.api_provider)
.to_string(),
),
"prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => Settings::load()
.ok()
.map(|settings| settings.prefer_external_pdftotext.to_string()),
"workspace_follow_symlinks" | "follow_symlinks" => Settings::load().ok().map(|settings| {
format!(
"{} (restart required for engine tools)",
settings.workspace_follow_symlinks
)
}),
_ => {
let known = Settings::available_settings()
.iter()
.any(|(k, _)| k == &key);
if known {
Some("(see /settings for current value)".to_string())
} else {
None
}
}
};
match value {
Some(v) => CommandResult::message(format!("{key} = {v}")),
None => CommandResult::error(format!(
"Unknown setting '{key}'. See `/help config` for available settings."
)),
}
}
pub fn settings_command(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg.map(str::trim).filter(|value| !value.is_empty()) {
None => CommandResult::action(AppAction::OpenConfigView),
Some("text" | "show" | "diagnostic" | "diagnostics") => show_settings(app),
Some(_) => CommandResult::error("Usage: /settings [text]"),
}
}
pub fn show_settings(app: &mut App) -> CommandResult {
match Settings::load() {
Ok(settings) => CommandResult::message(settings.display(app.ui_locale)),
Err(e) => CommandResult::error(format!("Failed to load settings: {e}")),
}
}
pub fn status_line(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::OpenStatusPicker)
}
pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult {
let next = match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => !app.verbose_transcript,
Some(raw) => match raw.to_ascii_lowercase().as_str() {
"on" | "true" | "1" | "yes" => true,
"off" | "false" | "0" | "no" => false,
"toggle" => !app.verbose_transcript,
_ => {
return CommandResult::error(
"Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.",
);
}
},
};
app.verbose_transcript = next;
app.mark_history_updated();
CommandResult::message(if next {
"Verbose transcript on: live thinking renders in full."
} else {
"Verbose transcript off: live thinking stays compact."
})
}
pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
if persist {
tokens.pop();
}
let target = match tokens.as_slice() {
[] | ["toggle"] => {
if app.sidebar_focus == SidebarFocus::Hidden {
SidebarFocus::Pinned
} else {
SidebarFocus::Hidden
}
}
[value] => match value.to_ascii_lowercase().as_str() {
"on" | "show" | "visible" | "pinned" => SidebarFocus::Pinned,
"off" | "hide" | "hidden" | "closed" | "none" => SidebarFocus::Hidden,
"auto" => SidebarFocus::Auto,
"work" | "plan" | "todos" => SidebarFocus::Pinned,
"tasks" | "activity" | "live" | "running" => SidebarFocus::Tasks,
"agents" | "subagents" | "sub-agents" => SidebarFocus::Agents,
"context" | "session" => SidebarFocus::Context,
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|pinned|auto|activity|tasks|agents|context] [--save]",
);
}
},
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|pinned|auto|activity|tasks|agents|context] [--save]",
);
}
};
if persist {
let result = set_config_value(app, "sidebar_focus", target.as_setting(), true);
if result.is_error {
return result;
}
} else {
app.set_sidebar_focus(target);
}
app.needs_redraw = true;
let message = sidebar_status_message(app);
CommandResult::message(message)
}
fn sidebar_status_message(app: &mut App) -> String {
match sidebar_render_state(app) {
SidebarRenderState::Hidden => "Sidebar is hidden".to_string(),
SidebarRenderState::SuppressedByWidth {
available_width,
min_width,
} => format!(
"Sidebar is on, but hidden because the terminal is too narrow ({available_width} cols; needs at least {min_width})"
),
SidebarRenderState::AutoCollapsed => {
"Sidebar auto mode is on, but currently collapsed while idle".to_string()
}
SidebarRenderState::Visible => "Sidebar is visible".to_string(),
}
}
fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("provider_url cannot be empty".to_string());
}
if provider == ApiProvider::XiaomiMimo {
match trimmed.to_ascii_lowercase().as_str() {
"token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => {
return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string());
}
"payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => {
return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string());
}
_ => {}
}
}
if trimmed.contains("://") {
Ok(trimmed.to_string())
} else if provider == ApiProvider::XiaomiMimo {
Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string())
} else {
Err("provider_url must be a URL".to_string())
}
}
fn parse_config_bool(value: &str) -> Result<bool, String> {
match value.trim().to_ascii_lowercase().as_str() {
"on" | "true" | "yes" | "1" | "enabled" => Ok(true),
"off" | "false" | "no" | "0" | "disabled" => Ok(false),
_ => Err(format!(
"Failed to parse boolean '{value}': expected on/off, true/false, yes/no."
)),
}
}
fn approval_mode_config_value(mode: ApprovalMode) -> &'static str {
match mode {
ApprovalMode::Auto => "auto",
ApprovalMode::Bypass => "bypass",
ApprovalMode::Suggest => "on-request",
ApprovalMode::Never => "never",
}
}
fn is_ask_rules_config_token(token: &str) -> bool {
matches!(
token.to_ascii_lowercase().as_str(),
"ask-rules"
| "ask_rules"
| "askrules"
| "rules"
| "permission-rules"
| "permission_rules"
| "permissions"
)
}
fn config_editability_audit(app: &App) -> CommandResult {
let config = match load_command_config(app) {
Ok(config) => config,
Err(err) => return CommandResult::error(err),
};
let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "(unresolved)".to_string());
let mut provider_config = config.clone();
provider_config.provider = Some(app.provider_identity_for_persistence().to_string());
let model = if app.auto_model {
"auto".to_string()
} else {
app.model.clone()
};
let saved_permission_posture = Settings::load()
.ok()
.and_then(|settings| settings.permission_posture)
.unwrap_or_else(|| "(unset)".to_string());
let configured_approval_policy = config
.approval_policy
.clone()
.unwrap_or_else(|| "(unset)".to_string());
let effective_permissions = if app.mode == AppMode::Plan {
"Read Only"
} else {
app.approval_mode.permission_chip_label()
};
let rows = [
(
"provider",
app.provider_identity_for_persistence().to_string(),
"session",
"/config provider <name>",
"Switches the active provider now; edit provider in config.toml for startup default.",
),
(
"model",
model,
"session",
"/config model <id|auto>",
"Switches the active model now; use default_text_model in config.toml for startup default.",
),
(
"effective_permissions",
effective_permissions.to_string(),
"runtime",
"Shift+Tab",
"Shows the effective Act permission posture; Plan remains Read Only.",
),
(
"permission_posture",
saved_permission_posture,
"TUI settings",
"Shift+Tab",
"Saved in settings.toml and ignored when config/requirements manage approval policy.",
),
(
"approval_policy",
configured_approval_policy,
"persisted config",
"/config approval_mode <auto|on-request|never> --save",
"Top-level managed policy; Full Access is not a valid value here.",
),
(
"allow_shell",
app.allow_shell.to_string(),
"runtime+persisted",
"/config allow_shell <true|false> --save",
"Writes top-level allow_shell and applies to subsequent turns.",
),
(
"stream_chunk_timeout_secs",
app.stream_chunk_timeout_secs.to_string(),
"runtime+persisted",
"/config stream_chunk_timeout_secs <0|1..3600> --save",
"Writes [tui].stream_chunk_timeout_secs and updates the running stream timeout.",
),
(
"subagents.enabled",
subagents_config_display_value(&config, "enabled"),
"runtime+persisted",
"/config subagents on|off --save",
"Writes [subagents].enabled and updates subsequent sub-agent launches.",
),
(
"subagents.max_concurrent",
subagents_config_display_value(&config, "max_concurrent"),
"runtime+persisted",
"/config subagents max_concurrent <n> --save",
"Clamped with Config::max_subagents and written to [subagents].max_concurrent.",
),
(
"subagents.max_depth",
subagents_config_display_value(&config, "max_depth"),
"runtime+persisted",
"/config subagents max_depth <n> --save",
"Clamped to the configured spawn-depth ceiling.",
),
(
"subagents.launch_concurrency",
subagents_config_display_value(&config, "launch_concurrency"),
"runtime+persisted",
"/config subagents launch_concurrency <n> --save",
"Clamped to the resolved sub-agent concurrency cap.",
),
(
"subagents.api_timeout_secs",
subagents_config_display_value(&config, "api_timeout_secs"),
"runtime+persisted",
"/config subagents api_timeout_secs <seconds> --save",
"0 means the compiled default; non-zero values are clamped to the documented range.",
),
(
"subagents.heartbeat_timeout_secs",
subagents_config_display_value(&config, "heartbeat_timeout_secs"),
"runtime+persisted",
"/config subagents heartbeat_timeout_secs <seconds> --save",
"0 means the compiled default; non-zero values are clamped to the documented range.",
),
(
"base_url",
config.deepseek_base_url(),
"persisted restart",
"/config base_url <url> --save",
"Writes top-level base_url; model clients read it on startup.",
),
(
"providers.<active>.base_url",
provider_config.deepseek_base_url(),
"persisted restart",
"/config provider_url <url> --save",
"Writes the active provider table; model clients read it on startup.",
),
(
"providers.<active>.context_window",
config_context_window_override(app)
.map_or_else(|| "(unset)".to_string(), |tokens| tokens.to_string()),
"persisted restart",
"edit [providers.<active>] context_window = <tokens>",
"Overrides compaction, context-pressure, header, and preflight input budgets; use 262144 to cap a 1M route to 256K.",
),
(
"effective_context_window",
format!(
"{} ({})",
crate::route_budget::route_context_window_tokens(
app.api_provider,
app.effective_model_for_budget(),
app.active_route_limits,
),
app.active_context_window_source.label(),
),
"runtime",
"/config context_window",
"The shared resolved window used by every active-route budget surface.",
),
(
"mcp_config_path",
app.mcp_config_path.display().to_string(),
"persisted live reload",
"/config mcp_config_path <path> --save",
"Run /mcp reload to rebuild the live model-visible tool pool.",
),
(
"workspace_follow_symlinks",
app.workspace_follow_symlinks.to_string(),
"partial restart",
"/config workspace_follow_symlinks <true|false> --save",
"Updates TUI file completion now; engine tools require restart.",
),
(
"instructions",
file_only_status(config.instructions.as_ref().map(|v| !v.is_empty())),
"file-only restart",
"edit config.toml",
"Prompt layers are loaded before the first turn.",
),
(
"hooks",
file_only_status(config.hooks.as_ref().map(|_| true)),
"file-only",
"edit config.toml",
"Hook definitions are structured TOML, not a scalar runtime setting.",
),
(
"network",
file_only_status(config.network.as_ref().map(|_| true)),
"file-only",
"edit config.toml",
"Network policy is evaluated by tool dispatch and should be reviewed as TOML.",
),
(
"tools",
file_only_status(config.tools.as_ref().map(|_| true)),
"file-only restart",
"edit config.toml",
"Tool catalog policy is built before model/tool negotiation.",
),
(
"memory",
file_only_status(config.memory.as_ref().map(|_| true)),
"file-only restart",
"edit config.toml",
"Memory loading changes prompt context and is resolved at startup.",
),
(
"runtime_api",
file_only_status(config.runtime_api.as_ref().map(|_| true)),
"file-only restart",
"edit config.toml",
"Serve/API tuning belongs to the runtime server startup path.",
),
(
"vision_model",
file_only_status(config.vision_model.as_ref().map(|_| true)),
"file-only restart",
"edit config.toml",
"Image-analysis provider clients are configured outside the scalar /config editor.",
),
];
let mut lines = Vec::new();
lines.push("Config editability audit".to_string());
lines.push(format!("Config path: {config_path}"));
lines.push("Key | Current | Editability | Command / reason".to_string());
for (key, current, editability, command, note) in rows {
lines.push(format!("{key} | {current} | {editability} | {command}"));
lines.push(format!(" {note}"));
}
CommandResult::message(lines.join("\n"))
}
fn file_only_status(configured: Option<bool>) -> String {
match configured {
Some(true) => "configured".to_string(),
Some(false) => "empty".to_string(),
None => "unset".to_string(),
}
}
fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
if raw == 0 {
format!("0 (default {resolved})")
} else {
resolved.to_string()
}
}
fn subagents_config_command(app: &mut App, raw: &str) -> CommandResult {
let mut tokens = raw.split_whitespace().collect::<Vec<_>>();
let persist = matches!(tokens.last(), Some(&"--save" | &"-s"));
if persist {
tokens.pop();
}
match tokens.as_slice() {
[] | ["status"] => subagents_status(app),
["on"] | ["enable"] | ["enabled"] => {
set_subagents_config_value(app, "enabled", "true", persist)
}
["off"] | ["disable"] | ["disabled"] => {
set_subagents_config_value(app, "enabled", "false", persist)
}
[key] => show_subagents_setting(app, key),
[key, value] => set_subagents_config_value(app, key, value, persist),
_ => CommandResult::error(
"Usage: /config subagents [status|on|off|enabled|max_concurrent|max_depth|launch_concurrency|api_timeout_secs|heartbeat_timeout_secs <value>] [--save]",
),
}
}
fn load_command_config(app: &App) -> Result<Config, String> {
Config::load(app.config_path.clone(), app.config_profile.as_deref())
.map_err(|err| format!("Failed to load config: {err}"))
}
fn subagents_status(app: &App) -> CommandResult {
let config = match load_command_config(app) {
Ok(config) => config,
Err(err) => return CommandResult::error(err),
};
let path = crate::config_persistence::config_toml_path(app.config_path.as_deref())
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "(unresolved)".to_string());
let disabled_reason = config.subagents_disabled_reason();
let active_provider = app.api_provider;
let subagents = config.subagents.as_ref();
let provider_subagents = config.subagent_provider_config(active_provider);
let explicit_enabled = subagents.and_then(|cfg| cfg.enabled);
let raw_max_concurrent = subagents.and_then(|cfg| cfg.max_concurrent);
let raw_max_depth = subagents.and_then(|cfg| cfg.max_depth);
let raw_launch = subagents.and_then(|cfg| cfg.launch_concurrency);
let raw_api = subagents.and_then(|cfg| cfg.api_timeout_secs);
let raw_heartbeat = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
let mut lines = Vec::new();
lines.push(format!(
"Sub-agents: {}",
disabled_reason
.map(|reason| format!("disabled ({reason})"))
.unwrap_or_else(|| "enabled".to_string())
));
lines.push(format!("Config path: {path}"));
lines.push(format!(
"Active provider: {} ({})",
active_provider.as_str(),
active_provider.display_name()
));
lines.push(format!(
"subagents.enabled = {}",
explicit_enabled
.map(|value| value.to_string())
.unwrap_or_else(|| "default true".to_string())
));
lines.push(format!(
"subagents.max_concurrent = {} (resolved global {}; active provider {})",
option_display(raw_max_concurrent),
config.max_subagents(),
config.max_subagents_for_provider(active_provider)
));
lines.push(format!(
"subagents.max_depth = {} (resolved global {}; active provider {})",
option_display(raw_max_depth),
config.subagent_max_spawn_depth(),
config.subagent_max_spawn_depth_for_provider(active_provider)
));
lines.push(format!(
"subagents.launch_concurrency = {} (resolved global {}; active provider {})",
option_display(raw_launch),
config.launch_concurrency(),
config.launch_concurrency_for_provider(active_provider)
));
lines.push(format!(
"subagents.api_timeout_secs = {} (resolved global {}; active provider {})",
option_display(raw_api),
config.subagent_api_timeout_secs(),
config.subagent_api_timeout_secs_for_provider(active_provider)
));
lines.push(format!(
"subagents.heartbeat_timeout_secs = {} (resolved global {}; active provider {})",
option_display(raw_heartbeat),
config.subagent_heartbeat_timeout_secs(),
config.subagent_heartbeat_timeout_secs_for_provider(active_provider)
));
if let Some(provider_subagents) = provider_subagents {
lines.push(format!(
"subagents.providers.{}.enabled = {}",
active_provider.as_str(),
provider_subagents
.enabled
.map(|value| value.to_string())
.unwrap_or_else(|| "inherits".to_string())
));
lines.push(format!(
"subagents.providers.{}.max_concurrent = {}",
active_provider.as_str(),
option_display(provider_subagents.max_concurrent)
));
lines.push(format!(
"subagents.providers.{}.max_depth = {}",
active_provider.as_str(),
option_display(provider_subagents.max_depth)
));
lines.push(format!(
"subagents.providers.{}.launch_concurrency = {}",
active_provider.as_str(),
option_display(provider_subagents.launch_concurrency)
));
lines.push(format!(
"subagents.providers.{}.max_admitted = {}",
active_provider.as_str(),
option_display(provider_subagents.max_admitted)
));
} else {
lines.push(format!(
"subagents.providers.{} = inherits global",
active_provider.as_str()
));
}
CommandResult::message(lines.join("\n"))
}
fn show_subagents_setting(app: &App, key: &str) -> CommandResult {
let config = match load_command_config(app) {
Ok(config) => config,
Err(err) => return CommandResult::error(err),
};
let Some(key) = canonical_subagents_key(key) else {
return CommandResult::error(format!(
"Unknown subagents setting '{key}'. Use `/config subagents status`."
));
};
let active_provider = app.api_provider;
let subagents = config.subagents.as_ref();
let value = match key {
"enabled" => subagents
.and_then(|cfg| cfg.enabled)
.map(|value| value.to_string())
.unwrap_or_else(|| "default true".to_string()),
"max_concurrent" => format!(
"{} (resolved global {}; active provider {})",
option_display(subagents.and_then(|cfg| cfg.max_concurrent)),
config.max_subagents(),
config.max_subagents_for_provider(active_provider)
),
"max_depth" => format!(
"{} (resolved global {}; active provider {})",
option_display(subagents.and_then(|cfg| cfg.max_depth)),
config.subagent_max_spawn_depth(),
config.subagent_max_spawn_depth_for_provider(active_provider)
),
"launch_concurrency" => format!(
"{} (resolved global {}; active provider {})",
option_display(subagents.and_then(|cfg| cfg.launch_concurrency)),
config.launch_concurrency(),
config.launch_concurrency_for_provider(active_provider)
),
"api_timeout_secs" => format!(
"{} (resolved global {}; active provider {})",
option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)),
config.subagent_api_timeout_secs(),
config.subagent_api_timeout_secs_for_provider(active_provider)
),
"heartbeat_timeout_secs" => format!(
"{} (resolved global {}; active provider {})",
option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)),
config.subagent_heartbeat_timeout_secs(),
config.subagent_heartbeat_timeout_secs_for_provider(active_provider)
),
_ => unreachable!("canonical subagent key"),
};
CommandResult::message(format!("subagents.{key} = {value}"))
}
fn option_display<T: std::fmt::Display>(value: Option<T>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "default".to_string())
}
fn canonical_subagents_key(key: &str) -> Option<&'static str> {
let normalized = key.trim().to_ascii_lowercase();
let key = normalized
.strip_prefix("subagents.")
.unwrap_or(normalized.as_str());
match key {
"enabled" | "enable" => Some("enabled"),
"max_concurrent" | "max_subagents" | "concurrency" | "cap" => Some("max_concurrent"),
"max_depth" | "depth" | "spawn_depth" => Some("max_depth"),
"launch_concurrency" | "launches" | "launch" => Some("launch_concurrency"),
"api_timeout_secs" | "api_timeout" | "step_timeout_secs" => Some("api_timeout_secs"),
"heartbeat_timeout_secs" | "heartbeat_timeout" | "heartbeat" => {
Some("heartbeat_timeout_secs")
}
_ => None,
}
}
fn set_subagents_config_value(
app: &mut App,
key: &str,
value: &str,
persist: bool,
) -> CommandResult {
let Some(key) = canonical_subagents_key(key) else {
return CommandResult::error(format!(
"Unknown subagents setting '{key}'. Use `/config subagents status`."
));
};
let mut config = match load_command_config(app) {
Ok(config) => config,
Err(err) => return CommandResult::error(err),
};
let current_max_subagents = config.max_subagents() as u64;
let subagents = config
.subagents
.get_or_insert_with(SubagentsConfig::default);
let mut note = None;
let save_result = match key {
"enabled" => {
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
Err(err) => return CommandResult::error(err),
};
subagents.enabled = Some(enabled);
if persist {
Some(persist_subagents_bool_key(
app.config_path.as_deref(),
"enabled",
enabled,
))
} else {
None
}
}
"max_concurrent" => {
let raw = match parse_subagents_u64(key, value) {
Ok(raw) => raw,
Err(err) => return CommandResult::error(err),
};
let clamped = raw.min(MAX_SUBAGENTS as u64);
if clamped != raw {
note = Some(format!("clamped from {raw} to {clamped}"));
}
subagents.max_concurrent = Some(clamped as usize);
if persist {
Some(persist_subagents_integer_key(
app.config_path.as_deref(),
"max_concurrent",
clamped,
))
} else {
None
}
}
"max_depth" => {
let raw = match parse_subagents_u64(key, value) {
Ok(raw) => raw,
Err(err) => return CommandResult::error(err),
};
let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING);
let clamped = raw.min(ceiling);
if clamped != raw {
note = Some(format!("clamped from {raw} to {clamped}"));
}
subagents.max_depth = Some(clamped as u32);
if persist {
Some(persist_subagents_integer_key(
app.config_path.as_deref(),
"max_depth",
clamped,
))
} else {
None
}
}
"launch_concurrency" => {
let raw = match parse_subagents_u64(key, value) {
Ok(raw) => raw,
Err(err) => return CommandResult::error(err),
};
let clamped = raw.clamp(1, current_max_subagents);
if clamped != raw {
note = Some(format!("clamped from {raw} to {clamped}"));
}
subagents.launch_concurrency = Some(clamped as usize);
if persist {
Some(persist_subagents_integer_key(
app.config_path.as_deref(),
"launch_concurrency",
clamped,
))
} else {
None
}
}
"api_timeout_secs" => {
let raw = match parse_subagents_u64(key, value) {
Ok(raw) => raw,
Err(err) => return CommandResult::error(err),
};
let stored = if raw == 0 {
0
} else {
raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS)
};
if stored != raw {
note = Some(format!("clamped from {raw} to {stored}"));
}
subagents.api_timeout_secs = Some(stored);
if persist {
Some(persist_subagents_integer_key(
app.config_path.as_deref(),
"api_timeout_secs",
stored,
))
} else {
None
}
}
"heartbeat_timeout_secs" => {
let raw = match parse_subagents_u64(key, value) {
Ok(raw) => raw,
Err(err) => return CommandResult::error(err),
};
let stored = if raw == 0 {
0
} else {
raw.clamp(
MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS,
)
};
if stored != raw {
note = Some(format!("clamped from {raw} to {stored}"));
}
subagents.heartbeat_timeout_secs = Some(stored);
if persist {
Some(persist_subagents_integer_key(
app.config_path.as_deref(),
"heartbeat_timeout_secs",
stored,
))
} else {
None
}
}
_ => unreachable!("canonical subagent key"),
};
let save_suffix = if let Some(result) = save_result {
match result {
Ok(path) => format!("saved to {}", path.display()),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
"session only, add --save to persist".to_string()
};
if key == "max_concurrent" {
app.max_subagents = config.max_subagents_for_provider(app.api_provider);
}
let display_value = subagents_config_display_value(&config, key);
let note = note.map(|note| format!("; {note}")).unwrap_or_default();
CommandResult::with_message_and_action(
format!(
"subagents.{key} = {display_value} ({save_suffix}; runtime updated for subsequent turns{note})"
),
subagents_runtime_action(app, &config),
)
}
fn parse_subagents_u64(key: &str, value: &str) -> Result<u64, String> {
value
.trim()
.parse::<u64>()
.map_err(|_| format!("subagents.{key} must be a whole number"))
}
fn subagents_config_display_value(config: &Config, key: &str) -> String {
let subagents = config.subagents.as_ref();
match key {
"enabled" => subagents
.and_then(|cfg| cfg.enabled)
.map(|value| value.to_string())
.unwrap_or_else(|| "default true".to_string()),
"max_concurrent" => {
if subagents.and_then(|cfg| cfg.max_concurrent) == Some(0) {
"0 (disabled)".to_string()
} else {
config.max_subagents().to_string()
}
}
"max_depth" => {
if subagents.and_then(|cfg| cfg.max_depth) == Some(0) {
"0 (agent tool disabled)".to_string()
} else {
config.subagent_max_spawn_depth().to_string()
}
}
"launch_concurrency" => config.launch_concurrency().to_string(),
"api_timeout_secs" => {
let raw = subagents.and_then(|cfg| cfg.api_timeout_secs);
if raw == Some(0) {
format!("0 (default {DEFAULT_SUBAGENT_API_TIMEOUT_SECS})")
} else {
config.subagent_api_timeout_secs().to_string()
}
}
"heartbeat_timeout_secs" => {
let raw = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs);
if raw == Some(0) {
format!("0 (default {DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS})")
} else {
config.subagent_heartbeat_timeout_secs().to_string()
}
}
_ => unreachable!("canonical subagent key"),
}
}
fn subagents_runtime_action(app: &App, config: &Config) -> AppAction {
let provider = app.api_provider;
let max_subagents = config
.max_subagents_for_provider(provider)
.clamp(1, MAX_SUBAGENTS);
AppAction::UpdateSubagentRuntimeConfig {
enabled: config.subagents_enabled_for_provider(provider),
max_subagents,
launch_concurrency: config.launch_concurrency_for_provider(provider),
max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
api_timeout_secs: config.subagent_api_timeout_secs_for_provider(provider),
heartbeat_timeout_secs: config.subagent_heartbeat_timeout_secs_for_provider(provider),
}
}
fn live_route_setting_subject(key: &str) -> Option<MessageId> {
match key {
"mode" => Some(MessageId::SettingSubjectMode),
"model" | "default_model" => Some(MessageId::SettingSubjectModel),
"reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking),
"provider" => Some(MessageId::SettingSubjectProvider),
"approval_mode" | "approval_policy" | "approval" => {
Some(MessageId::SettingSubjectPermissions)
}
_ => None,
}
}
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
let key = key.to_lowercase();
if let Some(subagent_key) = key.strip_prefix("subagents.") {
return set_subagents_config_value(app, subagent_key, value, persist);
}
if let Some(subject) = live_route_setting_subject(key.as_str())
&& app.is_loading
{
return CommandResult::error(app.setting_locked_message(subject));
}
match key.as_str() {
"model" => {
if value.trim().eq_ignore_ascii_case("auto") {
app.set_model_selection("auto".to_string());
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
return CommandResult::with_message_and_action(
format!(
"model = auto (auto-select model per turn; thinking = {})",
app.reasoning_effort_display_label()
),
AppAction::UpdateCompaction(app.compaction_config()),
);
}
let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else {
return CommandResult::error(format!(
"Invalid model '{value}' for provider {}.",
app.api_provider.as_str()
));
};
if let Err(reason) = validate_route(app.api_provider, &model) {
return CommandResult::error(reason);
}
app.set_model_selection(model.clone());
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
return CommandResult::with_message_and_action(
format!("model = {model}"),
AppAction::UpdateCompaction(app.compaction_config()),
);
}
"provider" => {
let value = value.trim();
let Some(provider) = ApiProvider::parse(value) else {
return CommandResult::error(format!(
"Unknown provider '{value}'. Use: {}.",
ApiProvider::names_hint()
));
};
if provider == app.api_provider {
return CommandResult::message(format!("provider = {}", provider.as_str()));
}
return CommandResult::with_message_and_action(
format!("provider = {}", provider.as_str()),
AppAction::SwitchProvider {
provider,
model: None,
},
);
}
"approval_mode" | "approval_policy" | "approval" => {
let use_tui_default = matches!(
value
.trim()
.to_ascii_lowercase()
.replace([' ', '_'], "-")
.as_str(),
"default" | "tui-default" | "use-tui-default"
);
if use_tui_default {
if !persist {
return CommandResult::error(
"Removing the config approval override requires --save.",
);
}
let control = match load_command_config(app) {
Ok(config) => config.approval_policy_control(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
),
Err(err) => return CommandResult::error(err),
};
if !matches!(
control,
crate::config::ApprovalPolicyControl::RootConfig
| crate::config::ApprovalPolicyControl::Unset
) {
return CommandResult::error(format!(
"Approval posture is controlled by {}; change that source first.",
control.label()
));
}
return match persist_unset_root_key(app.config_path.as_deref(), "approval_policy") {
Ok(path) => {
let saved_mode = Settings::load_persisted()
.ok()
.and_then(|settings| settings.permission_posture)
.as_deref()
.and_then(ApprovalMode::from_config_value)
.unwrap_or(ApprovalMode::Suggest);
app.set_agent_approval_posture(saved_mode);
app.clear_saved_approval_policy_lock();
CommandResult::with_message_and_action(
format!(
"approval_policy removed from {}; new sessions use the TUI {} default",
path.display(),
saved_mode.permission_chip_label()
),
AppAction::ApprovalPolicyPersisted { policy: None },
)
}
Err(err) => CommandResult::error(format!("Failed to save: {err}")),
};
}
let control = match load_command_config(app) {
Ok(config) => config.approval_policy_control(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
),
Err(err) => return CommandResult::error(err),
};
let control_allows_change = if persist {
control.editable_root()
} else {
matches!(control, crate::config::ApprovalPolicyControl::Unset)
};
if !control_allows_change {
return CommandResult::error(format!(
"Approval posture is controlled by {}; {}.",
control.label(),
if matches!(control, crate::config::ApprovalPolicyControl::RootConfig) {
"save a new config value or choose Use TUI permission default"
} else {
"change that source first"
}
));
}
let mode = ApprovalMode::from_config_value(value);
return match mode {
Some(ApprovalMode::Bypass)
if persist
&& matches!(control, crate::config::ApprovalPolicyControl::RootConfig) =>
{
match app.adopt_root_approval_posture(ApprovalMode::Bypass) {
Ok(()) => CommandResult::with_message_and_action(
"approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)",
AppAction::ApprovalPolicyPersisted { policy: None },
),
Err(reason) => {
CommandResult::error(format!("Failed to save Full Access: {reason}"))
}
}
}
Some(ApprovalMode::Bypass) if persist => CommandResult::error(
"Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.",
),
Some(m) => {
if persist {
let saved = approval_mode_config_value(m);
match persist_root_string_key(
app.config_path.as_deref(),
"approval_policy",
saved,
) {
Ok(path) => {
app.set_agent_approval_posture(m);
app.mark_approval_policy_locked();
CommandResult::with_message_and_action(
format!(
"approval_mode = {} (saved to {} as approval_policy = \"{}\")",
m.permission_chip_label(),
path.display(),
saved
),
AppAction::ApprovalPolicyPersisted {
policy: Some(saved.to_string()),
},
)
}
Err(err) => CommandResult::error(format!("Failed to save: {err}")),
}
} else {
app.set_agent_approval_posture(m);
CommandResult::with_message_and_action(
format!(
"approval_mode = {} (session only, add --save to persist)",
m.permission_chip_label()
),
AppAction::ModeChanged(app.mode),
)
}
}
None => CommandResult::error(
"Invalid approval_mode. Use: auto-review/auto, ask/suggest/on-request, full-access, never/deny",
),
};
}
"allow_shell" | "shell" | "exec_shell" => {
let control = match load_command_config(app) {
Ok(config) => config.allow_shell_control(
app.config_path.as_deref(),
app.config_profile.as_deref(),
&app.workspace,
),
Err(err) => return CommandResult::error(err),
};
if !control.editable_root() {
return CommandResult::error(format!(
"Shell access is controlled by {}; change that source first.",
control.label()
));
}
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
Err(err) => return CommandResult::error(err),
};
let suffix = if persist {
match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) {
Ok(path) => format!(" (saved to {})", path.display()),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
" (session only, add --save to persist)".to_string()
};
app.set_agent_shell_access(enabled);
let mode_hint = if enabled {
" Act mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves."
} else {
" Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`."
};
return CommandResult::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}"));
}
"mcp_config_path" | "mcp" => {
if value.trim().is_empty() {
return CommandResult::error("mcp_config_path cannot be empty");
}
let next_path = PathBuf::from(expand_tilde(value));
let path_changed = next_path != app.mcp_config_path;
app.mcp_config_path = next_path;
if path_changed {
app.mcp_reload_required = true;
}
let reload_note = if path_changed {
"; run /mcp reload to rebuild the live tool pool"
} else {
""
};
let message = if persist {
match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value)
{
Ok(path) => format!(
"mcp_config_path = {} (saved to {}){}",
app.mcp_config_path.display(),
path.display(),
reload_note
),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
format!(
"mcp_config_path = {} (session only){}",
app.mcp_config_path.display(),
reload_note
)
};
return CommandResult::message(message);
}
"base_url" => {
let value = value.trim();
if value.is_empty() {
return CommandResult::error("base_url cannot be empty");
}
if persist {
match persist_root_string_key(app.config_path.as_deref(), "base_url", value) {
Ok(path) => {
return CommandResult::message(format!(
"base_url = {value} (saved to {})",
path.display()
));
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
return CommandResult::error(
"base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
);
}
"provider_url" | "provider_base_url" | "endpoint" => {
let value = match resolve_provider_url_value(app.api_provider, value) {
Ok(value) => value,
Err(err) => return CommandResult::error(err),
};
if matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
) {
if persist {
match persist_root_string_key(app.config_path.as_deref(), "base_url", &value) {
Ok(path) => {
return CommandResult::message(format!(
"provider_url = {value} (saved to {}; restart required)",
path.display()
));
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
} else if persist {
match persist_provider_base_url_key(
app.config_path.as_deref(),
app.api_provider,
&value,
) {
Ok(path) => {
return CommandResult::message(format!(
"provider_url = {value} for {} (saved to {}; restart required)",
app.api_provider.as_str(),
path.display()
));
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
return CommandResult::error(
"provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.",
);
}
"stream_chunk_timeout_secs" => {
let raw = match value.trim().parse::<u64>() {
Ok(value) => value,
Err(_) => {
return CommandResult::error(
"stream_chunk_timeout_secs must be a whole number",
);
}
};
if raw != 0
&& !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw)
{
return CommandResult::error(format!(
"stream_chunk_timeout_secs must be 0 or {MIN_STREAM_CHUNK_TIMEOUT_SECS}..={MAX_STREAM_CHUNK_TIMEOUT_SECS}"
));
}
let resolved = if raw == 0 {
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
} else {
raw
};
app.stream_chunk_timeout_secs = resolved;
let value_label = stream_chunk_timeout_value_label(raw, resolved);
if persist {
match persist_tui_integer_key(
app.config_path.as_deref(),
"stream_chunk_timeout_secs",
raw,
) {
Ok(path) => {
return CommandResult::with_message_and_action(
format!(
"stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)",
path.display()
),
AppAction::UpdateStreamChunkTimeout(resolved),
);
}
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
}
return CommandResult::with_message_and_action(
format!(
"stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)"
),
AppAction::UpdateStreamChunkTimeout(resolved),
);
}
_ => {}
}
let mut settings = match Settings::load_persisted() {
Ok(s) => s,
Err(e) if !persist => {
app.status_message = Some(format!(
"Settings unavailable; applying session-only override ({e})"
));
Settings::default()
}
Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
};
if key == "default_model"
&& !matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
&& !persist
{
return CommandResult::error(format!(
"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.",
app.api_provider.as_str()
));
}
if let Err(e) = settings.set(&key, value) {
return CommandResult::error(format!("{e}"));
}
let mut effective_settings = settings.clone();
effective_settings.apply_env_overrides();
let mut action = None;
match key.as_str() {
"auto_compact" | "compact" => {
app.auto_compact = settings.auto_compact;
app.auto_compact_user_configured = true;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"auto_compact_threshold" | "auto_compact_threshold_percent" => {
app.auto_compact = true;
app.auto_compact_user_configured = true;
app.auto_compact_threshold_percent = settings.auto_compact_threshold_percent;
app.update_model_compaction_budget();
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"calm_mode" | "calm" => {
app.calm_mode = settings.calm_mode;
app.mark_history_updated();
}
"low_motion" | "motion" => {
app.low_motion = effective_settings.low_motion;
app.needs_redraw = true;
}
"fancy_animations" | "fancy" | "animations" => {
app.fancy_animations = effective_settings.fancy_animations;
app.needs_redraw = true;
}
"ocean_treatment" | "treatment" | "background_treatment" => {
app.ocean_treatment =
crate::tui::ocean::OceanTreatment::parse(&settings.ocean_treatment);
app.needs_redraw = true;
}
"focus_texture" | "texture" => {
app.focus_texture =
crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture)
.unwrap_or_default();
app.needs_redraw = true;
}
"work_surface_placement" | "work_surface" | "work_rail" => {
app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::parse(
&settings.work_surface_placement,
);
app.work_surface.focused = false;
app.work_surface.last_area = None;
app.needs_redraw = true;
}
"work_surface_top_height" | "work_top_height" => {
app.work_surface.top_height = settings.work_surface_top_height;
app.needs_redraw = true;
}
"work_surface_side_width" | "work_side_width" => {
app.work_surface.side_width = settings.work_surface_side_width;
app.needs_redraw = true;
}
"bracketed_paste" | "paste" => {
app.use_bracketed_paste = settings.bracketed_paste;
app.needs_redraw = true;
}
"status_indicator" | "indicator" => {
app.status_indicator = settings.status_indicator.clone();
app.needs_redraw = true;
}
"synchronized_output" | "sync_output" | "sync" => {
app.synchronized_output_enabled = effective_settings.synchronized_output_enabled();
app.needs_redraw = true;
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
app.mark_history_updated();
}
"thinking_default_expanded" | "thinking_expanded" => {
app.thinking_default_expanded = settings.thinking_default_expanded;
app.mark_history_updated();
}
"thinking_highlight" | "reasoning_highlight" => {
app.thinking_highlight = settings.thinking_highlight;
app.mark_history_updated();
}
"show_tool_details" | "tool_details" => {
app.show_tool_details = settings.show_tool_details;
app.mark_history_updated();
}
"inline_diffs" | "inline_diff" | "diffs" => {
app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs);
app.mark_history_updated();
app.needs_redraw = true;
}
"locale" | "language" => {
app.ui_locale = resolve_locale(&settings.locale);
app.mark_history_updated();
app.needs_redraw = true;
}
"theme" | "ui_theme" | "background_color" | "background" | "bg" => {
let background_color_override = if matches!(key.as_str(), "theme" | "ui_theme") {
app.background_color_override
} else {
settings
.background_color
.as_deref()
.and_then(crate::palette::parse_hex_rgb_color)
};
let background_setting =
background_color_override.and_then(crate::palette::hex_rgb_string);
let (_, theme_id, ui_theme) = match crate::palette::resolve_theme_setting(
&settings.theme,
background_setting.as_deref(),
) {
Ok(resolved) => resolved,
Err(error) => {
return CommandResult::error(format!("Failed to apply theme: {error}"));
}
};
app.background_color_override = background_color_override;
app.theme_id = theme_id;
app.ui_theme = ui_theme;
app.needs_redraw = true;
}
"cost_currency" | "currency" => {
app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency)
.unwrap_or(crate::pricing::CostCurrency::Usd);
app.needs_redraw = true;
}
"composer_density" | "composer" => {
app.composer_density =
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
app.needs_redraw = true;
}
"composer_border" | "border" => {
app.composer_border = settings.composer_border;
app.needs_redraw = true;
}
"composer_vim_mode" | "vim_mode" | "vim" => {
app.composer.vim_enabled = settings.composer_vim_mode == "vim";
app.composer.vim_mode = if app.composer.vim_enabled {
VimMode::Normal
} else {
VimMode::Insert
};
app.composer.vim_pending_d = false;
app.needs_redraw = true;
}
"paste_burst_detection" | "paste_burst" => {
app.use_paste_burst_detection = settings.paste_burst_detection;
if !app.use_paste_burst_detection {
app.paste_burst.clear_after_explicit_paste();
}
}
"mention_menu_limit" | "mention_limit" => {
app.mention_menu_limit = settings.mention_menu_limit;
app.composer.mention_completion_cache = None;
app.composer.mention_discovery.invalidate();
app.needs_redraw = true;
}
"mention_menu_behavior" | "mention_behavior" | "mention_menu" => {
app.mention_menu_behavior = settings.mention_menu_behavior.clone();
app.composer.mention_completion_cache = None;
app.composer.mention_discovery.invalidate();
app.needs_redraw = true;
}
"mention_walk_depth" | "mention_depth" | "completions_walk_depth" => {
app.mention_walk_depth = settings.mention_walk_depth;
app.composer.mention_completion_cache = None;
app.composer.mention_discovery.invalidate();
app.needs_redraw = true;
}
"workspace_follow_symlinks" | "follow_symlinks" => {
app.workspace_follow_symlinks = settings.workspace_follow_symlinks;
app.composer.mention_completion_cache = None;
app.composer.mention_discovery.invalidate();
app.needs_redraw = true;
return CommandResult::message(if persist {
if let Err(e) = persist_single_setting(&key, value) {
return CommandResult::error(format!("Failed to save: {e}"));
}
format!(
"workspace_follow_symlinks = {} (saved; restart required for engine tools)",
settings.workspace_follow_symlinks
)
} else {
format!(
"workspace_follow_symlinks = {} (session only for UI; restart required for engine tools)",
settings.workspace_follow_symlinks
)
});
}
"transcript_spacing" | "spacing" => {
app.transcript_spacing =
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
app.mark_history_updated();
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
app.tool_collapse_mode =
crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode);
app.expanded_tool_runs.clear();
app.mark_history_updated();
}
"default_mode" => {}
"mode" => {
let mode = AppMode::from_setting(&settings.default_mode);
app.set_mode(mode);
action = Some(AppAction::ModeChanged(mode));
}
"max_history" | "history" => {
app.max_input_history = settings.max_input_history;
}
"default_model" => {
if matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
) && let Some(ref model) = settings.default_model
{
app.set_model_selection(model.clone());
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
}
"reasoning_effort" | "effort" => {
app.reasoning_effort_preference = settings
.reasoning_effort
.as_deref()
.map(ReasoningEffort::from_setting);
app.reasoning_effort = app.reasoning_effort_preference.map_or_else(
|| {
if app.auto_model {
ReasoningEffort::Auto
} else {
ReasoningEffort::default()
}
},
|requested| {
if app.auto_model {
requested
} else {
requested.normalize_for_provider(app.api_provider)
}
},
);
app.invalidate_route_receipts_for_reasoning_change();
app.update_model_compaction_budget();
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"sidebar_width" | "sidebar" => {
app.sidebar_width_percent = settings.sidebar_width_percent;
app.mark_history_updated();
}
"sidebar_focus" | "focus" => {
app.set_sidebar_focus(SidebarFocus::from_setting(&settings.sidebar_focus));
}
"context_panel" | "context" | "session_panel" => {
app.context_panel = settings.context_panel;
app.needs_redraw = true;
}
"sessions_rail" | "sessions_panel" | "session_rail" => {
app.sessions_rail = settings.sessions_rail;
app.sessions_rail_cache = None;
app.needs_redraw = true;
}
_ => {}
}
let display_value = match key.as_str() {
"default_mode" | "mode" => settings.default_mode.clone(),
"cost_currency" | "currency" => settings.cost_currency.clone(),
"theme" | "ui_theme" => settings.theme.clone(),
"synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(),
"background_color" | "background" | "bg" => settings
.background_color
.clone()
.unwrap_or_else(|| "default".to_string()),
"reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else(
|| "config/default".to_string(),
|value| {
ReasoningEffort::from_setting_for_provider(value, app.api_provider)
.as_setting_for_provider(app.api_provider)
.to_string()
},
),
"composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(),
"low_motion" | "motion" => settings.low_motion.to_string(),
"fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(),
_ => value.to_string(),
};
let mut message = if persist {
if let Err(e) = persist_single_setting(&key, value) {
return CommandResult::error(format!("Failed to save: {e}"));
}
format!("{key} = {display_value} (saved)")
} else {
format!("{key} = {display_value} (session only, add --save to persist)")
};
if key == "default_model"
&& !matches!(
app.api_provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN
)
{
message.push_str(&format!(
"; DeepSeek fallback only — active {}/{} is unchanged",
app.api_provider.as_str(),
app.model_display_label()
));
}
CommandResult {
message: Some(message),
action,
is_error: false,
}
}
fn persist_single_setting(key: &str, value: &str) -> anyhow::Result<()> {
Settings::transact(|settings| settings.set(key, value))
}
pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else {
return CommandResult::action(AppAction::OpenModePicker);
};
match AppMode::parse(arg) {
Some(mode) => {
let (message, changed) = switch_mode_with_status(app, mode);
if changed {
CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode))
} else {
CommandResult::message(message)
}
}
None => CommandResult::error("Usage: /mode [act|agent|plan|operate|1|2|3]"),
}
}
pub fn switch_mode(app: &mut App, mode: AppMode) -> String {
switch_mode_with_status(app, mode).0
}
fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
match app.select_mode(mode) {
SettingSelection::Changed => (format!("Switched to {} mode.", mode.display_name()), true),
SettingSelection::PersistedSame => (app.mode_startup_default_receipt(mode), false),
SettingSelection::Refused => (
app.setting_locked_message(MessageId::SettingSubjectMode),
false,
),
}
}
pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => CommandResult::action(AppAction::OpenThemePicker),
Some("schema") => CommandResult::message(crate::palette::user_theme_schema_json()),
Some("path") => match crate::palette::user_themes_dir() {
Ok(path) => CommandResult::message(format!(
"User themes: {}\nSelect with: /theme custom:<name>",
path.display()
)),
Err(error) => CommandResult::error(error),
},
Some(name) => set_config_value(app, "theme", name, true),
}
}
pub fn slop(_app: &mut App, arg: Option<&str>) -> CommandResult {
let arg = arg.map(str::trim).unwrap_or("");
let ledger = match crate::slop_ledger::SlopLedger::load() {
Ok(l) => l,
Err(e) => return CommandResult::error(format!("Failed to load debt ledger: {e}")),
};
match arg {
"" => CommandResult::message(ledger.summary()),
"query" | "q" => {
if ledger.is_empty() {
return CommandResult::message("Debt ledger is empty.");
}
let mut out = String::new();
for entry in &ledger.query(&Default::default()) {
use std::fmt::Write;
let _ = writeln!(
out,
"[{}] {} ({:?} | {:?}) — {}",
crate::slop_ledger::short_id(&entry.id),
entry.bucket.as_str(),
entry.severity,
entry.status,
entry.title
);
}
CommandResult::message(out)
}
"export" | "e" => {
let md = ledger.export_markdown(None, None);
CommandResult::message(md)
}
_ => CommandResult::error(format!(
"Unknown /debt action '{arg}'. Use /debt, /debt query, or /debt export."
)),
}
}
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
let mut parts = raw.splitn(2, char::is_whitespace);
let sub = parts.next().unwrap_or("").to_lowercase();
let rest = parts.next().map(str::trim).unwrap_or("");
let workspace = app.workspace.clone();
match sub.as_str() {
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
"on" | "enable" | "yes" | "y" => {
app.trust_mode = true;
CommandResult::message(
"Workspace trust mode enabled — agent file tools can now read/write any path. \
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
)
}
"off" | "disable" | "no" | "n" => {
app.trust_mode = false;
CommandResult::message("Workspace trust mode disabled.")
}
"add" => trust_add(&workspace, rest),
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
other => CommandResult::error(format!(
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
)),
}
}
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
let mut lines = Vec::new();
lines.push(format!(
"Workspace trust mode: {}",
if app.trust_mode {
"enabled"
} else {
"disabled"
}
));
if trust.paths().is_empty() {
if force_paths {
lines.push("No external paths trusted from this workspace.".to_string());
} else {
lines.push(
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
.to_string(),
);
}
} else {
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
for path in trust.paths() {
lines.push(format!(" • {}", path.display()));
}
}
CommandResult::message(lines.join("\n"))
}
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
if raw.is_empty() {
return CommandResult::error(
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
);
}
let path = PathBuf::from(expand_tilde(raw));
if !path.exists() {
return CommandResult::error(format!(
"Path not found: {} — supply an existing directory or file.",
path.display()
));
}
match crate::workspace_trust::add(workspace, &path) {
Ok(stored) => CommandResult::message(format!(
"Added to trust list for this workspace: {}",
stored.display()
)),
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
}
}
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
if raw.is_empty() {
return CommandResult::error("Usage: /trust remove <path>");
}
let path = PathBuf::from(expand_tilde(raw));
match crate::workspace_trust::remove(workspace, &path) {
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
}
}
fn expand_tilde(raw: &str) -> String {
if let Some(rest) = raw.strip_prefix("~/")
&& let Some(home) = crate::config::effective_home_dir()
{
return home.join(rest).to_string_lossy().into_owned();
} else if raw == "~"
&& let Some(home) = crate::config::effective_home_dir()
{
return home.to_string_lossy().into_owned();
}
raw.to_string()
}
pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
let raw = arg.map(str::trim).unwrap_or("");
let current_enabled = app.lsp_enabled;
match raw {
"" | "status" => {
let status = if current_enabled { "on" } else { "off" };
CommandResult::message(format!(
"LSP diagnostics are currently **{status}**.\n\n\
Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits."
))
}
"on" | "enable" | "1" | "true" => {
app.lsp_enabled = true;
CommandResult::message(
"LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.",
)
}
"off" | "disable" | "0" | "false" => {
app.lsp_enabled = false;
CommandResult::message("LSP diagnostics disabled.")
}
other => CommandResult::error(format!(
"Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`."
)),
}
}
pub fn logout(app: &mut App) -> CommandResult {
let provider_name = app.provider_identity_for_persistence().to_string();
match clear_active_provider_api_key(&provider_name) {
Ok(()) => {
app.onboarding = OnboardingState::ApiKey;
app.onboarding_needs_api_key = true;
app.api_key_input.clear();
app.api_key_cursor = 0;
CommandResult::message(format!(
"Cleared API key for {provider_name}. \
Use `codewhale auth clear --provider <id>` to clear a different provider."
))
}
Err(e) => CommandResult::error(format!("Failed to clear API key for {provider_name}: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::{App, TuiOptions};
use crate::tui::approval::ApprovalMode;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct EnvGuard {
home: Option<OsString>,
userprofile: Option<OsString>,
codewhale_config_path: Option<OsString>,
deepseek_config_path: Option<OsString>,
codewhale_allow_shell: Option<OsString>,
deepseek_allow_shell: Option<OsString>,
deepseek_approval_policy: Option<OsString>,
no_animations: Option<OsString>,
term_program: Option<OsString>,
ptyxis_version: Option<OsString>,
_lock: crate::test_support::TestEnvLock,
}
impl EnvGuard {
fn new(home: &Path) -> Self {
let lock = crate::test_support::lock_test_env();
let home_str = OsString::from(home.as_os_str());
let config_path = home.join(".deepseek").join("config.toml");
let config_str = OsString::from(config_path.as_os_str());
let home_prev = env::var_os("HOME");
let userprofile_prev = env::var_os("USERPROFILE");
let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
let codewhale_allow_shell_prev = env::var_os("CODEWHALE_ALLOW_SHELL");
let deepseek_allow_shell_prev = env::var_os("DEEPSEEK_ALLOW_SHELL");
let deepseek_approval_policy_prev = env::var_os("DEEPSEEK_APPROVAL_POLICY");
let no_animations_prev = env::var_os("NO_ANIMATIONS");
let term_program_prev = env::var_os("TERM_PROGRAM");
let ptyxis_version_prev = env::var_os("PTYXIS_VERSION");
unsafe {
env::set_var("HOME", &home_str);
env::set_var("USERPROFILE", &home_str);
env::remove_var("CODEWHALE_CONFIG_PATH");
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
env::remove_var("CODEWHALE_ALLOW_SHELL");
env::remove_var("DEEPSEEK_ALLOW_SHELL");
env::remove_var("DEEPSEEK_APPROVAL_POLICY");
env::remove_var("NO_ANIMATIONS");
env::remove_var("TERM_PROGRAM");
env::remove_var("PTYXIS_VERSION");
}
Self {
home: home_prev,
userprofile: userprofile_prev,
codewhale_config_path: codewhale_config_prev,
deepseek_config_path: deepseek_config_prev,
codewhale_allow_shell: codewhale_allow_shell_prev,
deepseek_allow_shell: deepseek_allow_shell_prev,
deepseek_approval_policy: deepseek_approval_policy_prev,
no_animations: no_animations_prev,
term_program: term_program_prev,
ptyxis_version: ptyxis_version_prev,
_lock: lock,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = self.home.take() {
unsafe {
env::set_var("HOME", value);
}
} else {
unsafe {
env::remove_var("HOME");
}
}
if let Some(value) = self.userprofile.take() {
unsafe {
env::set_var("USERPROFILE", value);
}
} else {
unsafe {
env::remove_var("USERPROFILE");
}
}
if let Some(value) = self.codewhale_config_path.take() {
unsafe {
env::set_var("CODEWHALE_CONFIG_PATH", value);
}
} else {
unsafe {
env::remove_var("CODEWHALE_CONFIG_PATH");
}
}
if let Some(value) = self.deepseek_config_path.take() {
unsafe {
env::set_var("DEEPSEEK_CONFIG_PATH", value);
}
} else {
unsafe {
env::remove_var("DEEPSEEK_CONFIG_PATH");
}
}
for (key, value) in [
("CODEWHALE_ALLOW_SHELL", self.codewhale_allow_shell.take()),
("DEEPSEEK_ALLOW_SHELL", self.deepseek_allow_shell.take()),
(
"DEEPSEEK_APPROVAL_POLICY",
self.deepseek_approval_policy.take(),
),
] {
unsafe {
if let Some(value) = value {
env::set_var(key, value);
} else {
env::remove_var(key);
}
}
}
if let Some(value) = self.no_animations.take() {
unsafe {
env::set_var("NO_ANIMATIONS", value);
}
} else {
unsafe {
env::remove_var("NO_ANIMATIONS");
}
}
for (key, value) in [
("TERM_PROGRAM", self.term_program.take()),
("PTYXIS_VERSION", self.ptyxis_version.take()),
] {
unsafe {
if let Some(value) = value {
env::set_var(key, value);
} else {
env::remove_var(key);
}
}
}
}
}
fn create_test_app_with_config(config: &Config) -> App {
let options = TuiOptions {
model: "test-model".to_string(),
start_in_agent_mode: true,
skip_onboarding: false,
..crate::test_support::test_tui_options(PathBuf::from("."))
};
let mut app = App::new(options, config);
app.model = "test-model".to_string();
app.auto_model = false;
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app
}
fn create_test_app() -> App {
create_test_app_with_config(&Config::default())
}
#[test]
fn the_shipped_preset_passes_its_own_preflight() {
let app = create_test_app();
let fields = crate::settings::preset_fields("calm").expect("the calm preset exists");
assert_eq!(preset_preflight(&app, fields), None);
}
#[test]
fn preset_preflight_refuses_an_invalid_field_before_any_write() {
let app = create_test_app();
let refusal = preset_preflight(&app, &[("calm_mode", "true"), ("low_motion", "banana")])
.expect("an invalid value must be refused");
assert!(
refusal.contains("low_motion"),
"the refusal must name the offending field, got {refusal:?}"
);
}
#[test]
fn preset_preflight_refuses_a_live_route_field_while_a_turn_runs() {
let mut app = create_test_app();
app.is_loading = true;
let bundle = [("calm_mode", "true"), ("reasoning_effort", "high")];
let refusal =
preset_preflight(&app, &bundle).expect("a live-route field must be refused mid-turn");
assert!(
refusal.contains("locked while a turn is running"),
"got {refusal:?}"
);
app.is_loading = false;
assert_eq!(
preset_preflight(&app, &bundle),
None,
"the same bundle must apply once the turn ends"
);
}
#[test]
fn live_route_key_list_covers_every_route_mutating_alias() {
for key in [
"mode",
"model",
"default_model",
"reasoning_effort",
"effort",
"provider",
"approval_mode",
"approval_policy",
"approval",
] {
assert!(
live_route_setting_subject(key).is_some(),
"{key} mutates the active route and must be locked mid-turn"
);
}
for key in ["default_mode", "theme", "calm_mode", "sidebar_width"] {
assert!(
live_route_setting_subject(key).is_none(),
"{key} does not mutate the active route and must stay settable"
);
}
}
#[test]
fn approval_aliases_are_inert_while_a_turn_is_running() {
let mut app = create_test_app();
app.approval_mode = ApprovalMode::Suggest;
app.is_loading = true;
for key in ["approval_mode", "approval_policy", "approval"] {
let result = set_config_value(&mut app, key, "never", false);
assert!(result.is_error, "{key} must be refused mid-turn");
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("locked while a turn is running")),
"unexpected refusal for {key}: {:?}",
result.message
);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
}
}
#[test]
fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() {
let mut app = create_test_app();
app.calm_mode = false;
app.show_thinking = true;
app.show_tool_details = true;
app.fancy_animations = true;
let result = config_command(&mut app, Some("preset calm"));
let message = result.message.unwrap_or_default();
assert!(
message.contains("calm"),
"summary should name the preset: {message}"
);
assert!(app.calm_mode);
assert!(!app.show_tool_details);
assert!(app.low_motion);
assert!(!app.fancy_animations);
assert_eq!(
app.tool_collapse_mode,
crate::tui::app::ToolCollapseMode::Calm
);
assert_eq!(
app.transcript_spacing,
crate::tui::app::TranscriptSpacing::Compact
);
assert!(app.show_thinking, "calm preset must not hide thinking");
}
#[test]
fn config_preset_unknown_name_reports_error() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("preset turbo"));
let message = result.message.unwrap_or_default();
assert!(
message.to_lowercase().contains("unknown preset"),
"expected unknown-preset error, got: {message}"
);
}
#[test]
fn config_preset_save_without_name_reports_usage() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("preset --save"));
let message = result.message.unwrap_or_default();
assert!(
message.contains("Usage: /config preset"),
"expected usage hint, got: {message}"
);
assert!(!result.is_error);
}
#[test]
fn work_surface_config_applies_live_and_rejects_bottom() {
let mut app = create_test_app();
let result = set_config_value(&mut app, "work_surface_placement", "left", false);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Left
);
let shown = show_single_setting(&app, "work_surface_placement");
assert_eq!(
shown.message.as_deref(),
Some("work_surface_placement = left")
);
let result = set_config_value(&mut app, "work_surface_placement", "bottom", false);
assert!(result.is_error);
assert_eq!(
app.work_surface.placement,
crate::tui::work_surface::WorkSurfacePlacement::Left
);
}
#[test]
fn sidebar_config_command_restores_pinned_sidebar_by_default() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(120);
let result = sidebar(&mut app, Some("on"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(result.message.as_deref(), Some("Sidebar is visible"));
}
#[test]
fn sidebar_config_command_reports_width_suppression() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(59);
let result = sidebar(&mut app, Some("on"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(
result.message.as_deref(),
Some(
"Sidebar is on, but hidden because the terminal is too narrow (59 cols; needs at least 60)"
)
);
}
#[test]
fn sidebar_config_command_is_visible_at_minimum_width() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(60);
let result = sidebar(&mut app, Some("on"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Pinned);
assert_eq!(result.message.as_deref(), Some("Sidebar is visible"));
}
#[test]
fn sidebar_config_command_reports_auto_idle_collapse() {
let mut app = create_test_app();
app.sidebar_focus = SidebarFocus::Hidden;
app.last_sidebar_host_width = Some(120);
let result = sidebar(&mut app, Some("auto"));
assert!(!result.is_error);
assert_eq!(app.sidebar_focus, SidebarFocus::Auto);
assert_eq!(
result.message.as_deref(),
Some("Sidebar auto mode is on, but currently collapsed while idle")
);
}
#[test]
fn test_mode_yolo_sets_all_flags() {
let mut app = create_test_app();
let _ = mode(&mut app, Some("agent"));
let result = mode(&mut app, Some("yolo"));
assert!(result.message.unwrap().contains("Switched to Act mode"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
assert!(app.allow_shell);
assert!(app.trust_mode);
assert!(app.yolo);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert_eq!(app.mode, AppMode::Agent);
}
#[test]
fn test_mode_switch_command_accepts_names_and_numbers() {
let mut app = create_test_app();
let _ = mode(&mut app, Some("agent"));
assert_eq!(app.mode, AppMode::Agent);
let result = mode(&mut app, Some("2"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan)));
assert_eq!(app.mode, AppMode::Plan);
let result = mode(&mut app, Some("act"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
assert_eq!(app.mode, AppMode::Agent);
let _ = mode(&mut app, Some("plan"));
assert_eq!(app.mode, AppMode::Plan);
let result = mode(&mut app, Some("3"));
assert_eq!(
result.action,
Some(AppAction::ModeChanged(AppMode::Operate))
);
assert_eq!(app.mode, AppMode::Operate);
let result = mode(&mut app, Some("5"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Operate);
let result = mode(&mut app, Some("9"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Operate);
let result = mode(&mut app, Some("4"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
assert_eq!(app.mode, AppMode::Agent);
assert!(app.yolo);
}
#[test]
fn test_mode_without_arg_opens_picker() {
let mut app = create_test_app();
let result = mode(&mut app, None);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenModePicker)));
}
#[test]
fn test_mode_rejects_unknown_value() {
let mut app = create_test_app();
let result = mode(&mut app, Some("fast"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage: /mode"));
}
#[test]
fn test_show_config_defaults_to_native() {
let mut app = create_test_app();
app.session.total_tokens = 1234;
let result = show_config(&mut app, None);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
#[test]
fn test_show_config_native_opens_legacy_editor() {
let mut app = create_test_app();
let result = show_config(&mut app, Some("native"));
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::OpenConfigView)));
}
#[test]
fn test_show_settings_loads_from_file() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = show_settings(&mut app);
assert!(result.message.is_some());
}
#[test]
fn settings_command_opens_typed_editor_and_preserves_text_mode() {
let _lock = lock_test_env();
let mut app = create_test_app();
let modal = settings_command(&mut app, None);
assert!(modal.message.is_none());
assert!(matches!(modal.action, Some(AppAction::OpenConfigView)));
let text = settings_command(&mut app, Some("text"));
let message = text.message.as_deref().expect("settings diagnostic text");
assert!(message.contains("Settings:"), "{message}");
assert!(message.contains("provider_models:"), "{message}");
assert!(message.contains("Config file:"), "{message}");
assert!(text.action.is_none());
}
#[test]
fn config_model_updates_app_state() {
let mut app = create_test_app();
let _old_model = app.model.clone();
let result = config_command(&mut app, Some("model deepseek-v4-flash"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-v4-flash"));
assert_eq!(app.model, "deepseek-v4-flash");
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn config_model_rejects_foreign_model_for_direct_provider() {
let mut app = create_test_app();
app.api_provider = ApiProvider::Zai;
app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
let result = set_config_value(&mut app, "model", "deepseek-v4-pro", false);
assert!(result.is_error);
assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
assert!(result.action.is_none());
let message = result.message.as_deref().expect("rejection message");
assert!(
message.contains("not compatible with provider 'zai'")
|| message.contains("not served by direct provider zai"),
"unexpected rejection message: {message}"
);
assert!(message.contains("deepseek-v4-pro"), "{message}");
}
#[test]
fn config_model_auto_preserves_explicit_thinking() {
let mut app = create_test_app();
app.reasoning_effort = ReasoningEffort::Off;
app.reasoning_effort_preference = Some(ReasoningEffort::Off);
let result = config_command(&mut app, Some("model auto"));
assert!(result.message.is_some());
assert!(app.auto_model);
assert_eq!(app.model, "auto");
assert_eq!(app.reasoning_effort, ReasoningEffort::Off);
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("thinking = off"))
);
assert!(app.last_effective_model.is_none());
assert!(app.last_effective_reasoning_effort.is_none());
}
#[test]
fn config_model_auto_releases_implicit_fixed_model_thinking() {
let mut app = create_test_app();
app.reasoning_effort = ReasoningEffort::Max;
app.reasoning_effort_preference = None;
let result = config_command(&mut app, Some("model auto"));
assert!(result.message.is_some());
assert!(app.auto_model);
assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
assert_eq!(app.reasoning_effort_preference, None);
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("thinking = auto"))
);
}
#[test]
fn config_reasoning_effort_applies_while_model_routing_is_auto() {
let mut app = create_test_app();
app.set_model_selection("auto".to_string());
app.reasoning_effort = ReasoningEffort::Auto;
app.reasoning_effort_preference = None;
let result = set_config_value(&mut app, "reasoning_effort", "low", false);
assert!(!result.is_error);
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low));
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn config_default_model_cannot_replace_a_non_deepseek_live_route() {
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-provider-scoped-default-model-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
app.api_provider = ApiProvider::Zai;
app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string();
app.auto_model = false;
let session_only = set_config_value(&mut app, "default_model", "deepseek-v4-flash", false);
assert!(session_only.is_error);
assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
assert!(session_only.action.is_none());
assert!(
session_only
.message
.as_deref()
.is_some_and(|message| message.contains("DeepSeek startup fallback"))
);
let saved = set_config_value(&mut app, "default_model", "deepseek-v4-flash", true);
assert!(!saved.is_error);
assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL);
assert!(saved.action.is_none());
assert!(
saved
.message
.as_deref()
.is_some_and(|message| message.contains("active zai/GLM-5.2 is unchanged"))
);
let persisted = Settings::load_persisted().expect("saved settings");
assert_eq!(
persisted.default_model.as_deref(),
Some("deepseek-v4-flash")
);
}
#[test]
fn config_reasoning_effort_uses_codex_provider_labels() {
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-codex-effort-config-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
app.api_provider = ApiProvider::OpenaiCodex;
app.reasoning_effort = ReasoningEffort::High;
let result = set_config_value(&mut app, "reasoning_effort", "off", false);
assert_eq!(app.reasoning_effort, ReasoningEffort::Low);
assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off));
assert_eq!(
result.message.as_deref(),
Some("reasoning_effort = low (session only, add --save to persist)")
);
let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false);
assert_eq!(app.reasoning_effort, ReasoningEffort::Max);
assert_eq!(
result.message.as_deref(),
Some("reasoning_effort = xhigh (session only, add --save to persist)")
);
}
#[test]
fn config_fancy_animations_keeps_ghostty_frame_cap_without_disabling_motion() {
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-ghostty-fancy-config-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let prev_term_program = env::var_os("TERM_PROGRAM");
unsafe {
env::set_var("TERM_PROGRAM", "Ghostty");
}
let mut app = create_test_app();
assert!(app.fancy_animations);
assert!(app.constrained_frame_rate);
let result = set_config_value(&mut app, "fancy_animations", "true", false);
assert!(!result.is_error);
assert!(
app.fancy_animations,
"Ghostty compatibility must cap redraws without disabling motion"
);
assert_eq!(
result.message.as_deref(),
Some("fancy_animations = true (session only, add --save to persist)")
);
unsafe {
match prev_term_program {
Some(v) => env::set_var("TERM_PROGRAM", v),
None => env::remove_var("TERM_PROGRAM"),
}
}
}
#[test]
fn config_model_accepts_future_deepseek_model_id() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("model deepseek-v4"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-v4"));
assert_eq!(app.model, "deepseek-v4");
}
#[test]
fn config_model_with_save_flag() {
let temp_root = tempfile::tempdir().expect("isolated settings dir");
let _guard = EnvGuard::new(temp_root.path());
let mut app = create_test_app();
let _result = config_command(&mut app, Some("model deepseek-v4-flash --save"));
assert_eq!(app.model, "deepseek-v4-flash");
}
#[test]
fn config_default_mode_normal_save_reports_normalized_value() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-default-mode-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(&mut app, Some("default_mode normal --save"));
let msg = result.message.unwrap();
assert_eq!(msg, "default_mode = agent (saved)");
assert_eq!(app.mode, AppMode::Agent);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
assert!(saved.contains("default_mode = \"agent\""));
}
#[test]
fn config_command_cost_currency_save_persists_value() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-cost-currency-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(&mut app, Some("cost_currency cny --save"));
let msg = result.message.unwrap();
assert_eq!(msg, "cost_currency = cny (saved)");
assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
assert!(saved.contains("cost_currency = \"cny\""));
}
#[test]
fn config_command_base_url_save_persists_value() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(
&mut app,
Some("base_url https://example.internal.local/v1 --save"),
);
let msg = result.message.unwrap();
let saved_path = crate::config_persistence::config_toml_path(None).unwrap();
let saved = fs::read_to_string(&saved_path).unwrap();
assert_eq!(
msg,
format!(
"base_url = https://example.internal.local/v1 (saved to {})",
saved_path.display()
)
);
assert!(saved.contains("base_url = \"https://example.internal.local/v1\""));
}
#[test]
fn config_command_provider_emits_switch_action() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("provider openrouter"));
assert!(!result.is_error);
assert_eq!(result.message.as_deref(), Some("provider = openrouter"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Openrouter);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn config_command_provider_rejects_unknown_provider() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("provider not-a-provider"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(msg.contains("Unknown provider 'not-a-provider'"));
assert!(msg.contains("openrouter"));
assert!(msg.contains("xiaomi-mimo"));
}
#[test]
fn config_command_allow_shell_enables_agent_shell_session_only() {
let mut app = create_test_app();
assert!(!app.allow_shell);
let result = config_command(&mut app, Some("allow_shell true"));
assert!(!result.is_error);
assert!(app.allow_shell);
let msg = result.message.unwrap();
assert!(msg.contains("allow_shell = true"));
assert!(msg.contains("session only"));
assert!(msg.contains("Act mode"));
assert!(msg.contains("approval gating"));
assert!(msg.contains("next turn"));
assert!(msg.contains("Full Access (Shift+Tab) also enables shell and auto-approves"));
}
#[test]
fn config_command_allow_shell_save_persists_root_boolean() {
let temp_root = tempfile::tempdir().expect("isolated config dir");
let _guard = EnvGuard::new(temp_root.path());
let config_path = temp_root.path().join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("allow_shell true --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert!(app.allow_shell);
assert_eq!(
msg,
format!(
"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.",
config_path.display()
)
);
assert!(saved.contains("allow_shell = true"));
}
#[test]
fn config_command_allow_shell_rejects_invalid_boolean() {
let mut app = create_test_app();
let result = config_command(&mut app, Some("allow_shell maybe"));
assert!(result.is_error);
assert!(!app.allow_shell);
let msg = result.message.unwrap();
assert!(msg.contains("Failed to parse boolean 'maybe'"));
}
#[test]
fn config_command_cannot_bypass_project_shell_constraint() {
let temp_root = env::temp_dir().join(format!(
"codewhale-project-shell-control-test-{}",
std::process::id()
));
fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
let _guard = EnvGuard::new(&temp_root);
let root_config = temp_root.join(".deepseek").join("config.toml");
fs::write(&root_config, "# user root\n").unwrap();
let workspace = temp_root.join("workspace");
fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
fs::write(
workspace
.join(codewhale_config::CODEWHALE_APP_DIR)
.join("config.toml"),
"allow_shell = false\n",
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(root_config.clone());
app.workspace = workspace;
app.set_agent_shell_access(false);
let result = config_command(&mut app, Some("allow_shell true --save"));
assert!(result.is_error, "{:?}", result.message);
assert!(!app.allow_shell);
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("project configuration"))
);
assert!(
!fs::read_to_string(root_config)
.unwrap()
.contains("allow_shell")
);
}
#[test]
fn config_command_cannot_bypass_environment_shell_constraint() {
let temp_root = env::temp_dir().join(format!(
"codewhale-env-shell-control-test-{}",
std::process::id()
));
fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::write(&config_path, "# root\n").unwrap();
unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") };
let config = Config::load(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&config);
app.config_path = Some(config_path);
app.set_agent_shell_access(false);
let result = config_command(&mut app, Some("allow_shell true"));
assert!(result.is_error, "{:?}", result.message);
assert!(!app.allow_shell);
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("DEEPSEEK_ALLOW_SHELL"))
);
}
#[test]
fn config_command_cannot_bypass_project_or_environment_approval() {
let temp_root = env::temp_dir().join(format!(
"codewhale-external-approval-control-test-{}",
std::process::id()
));
fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
let _guard = EnvGuard::new(&temp_root);
let root_config = temp_root.join(".deepseek").join("config.toml");
fs::write(&root_config, "# root\n").unwrap();
let workspace = temp_root.join("workspace");
fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap();
fs::write(
workspace
.join(codewhale_config::CODEWHALE_APP_DIR)
.join("config.toml"),
"approval_policy = \"never\"\n",
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(root_config.clone());
app.workspace = workspace;
app.set_agent_approval_posture(ApprovalMode::Never);
let project_result = config_command(&mut app, Some("approval_mode full-access"));
assert!(project_result.is_error, "{:?}", project_result.message);
assert_eq!(app.approval_mode, ApprovalMode::Never);
app.workspace = temp_root.join("clean-workspace");
fs::create_dir_all(&app.workspace).unwrap();
unsafe { env::set_var("DEEPSEEK_APPROVAL_POLICY", "never") };
let env_result = config_command(&mut app, Some("approval_mode auto"));
assert!(env_result.is_error, "{:?}", env_result.message);
assert_eq!(app.approval_mode, ApprovalMode::Never);
assert!(
env_result
.message
.as_deref()
.is_some_and(|message| message.contains("DEEPSEEK_APPROVAL_POLICY"))
);
}
#[test]
fn config_command_shell_choice_survives_plan_round_trip() {
let mut app = create_test_app();
app.set_agent_approval_posture(ApprovalMode::Bypass);
let result = config_command(&mut app, Some("allow_shell true"));
assert!(!result.is_error, "{:?}", result.message);
app.set_mode(AppMode::Plan);
assert!(!app.allow_shell);
app.set_mode(AppMode::Agent);
assert!(app.allow_shell);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
}
#[test]
fn config_command_subagents_off_save_persists_and_updates_runtime() {
let temp_root = env::temp_dir().join(format!(
"codewhale-subagents-off-save-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("subagents off --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert!(!result.is_error);
assert!(msg.contains("subagents.enabled = false"));
assert!(msg.contains("saved to"));
assert!(saved.contains("[subagents]"));
assert!(saved.contains("enabled = false"));
match result.action {
Some(AppAction::UpdateSubagentRuntimeConfig { enabled, .. }) => {
assert!(!enabled);
}
other => panic!("expected subagent runtime update, got {other:?}"),
}
}
#[test]
fn config_command_subagents_depth_save_clamps_to_ceiling() {
let temp_root = env::temp_dir().join(format!(
"codewhale-subagents-depth-save-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("subagents max_depth 99 --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING;
assert!(!result.is_error);
assert!(msg.contains(&format!("subagents.max_depth = {ceiling}")));
assert!(msg.contains(&format!("clamped from 99 to {ceiling}")));
assert!(saved.contains(&format!("max_depth = {ceiling}")));
match result.action {
Some(AppAction::UpdateSubagentRuntimeConfig {
max_spawn_depth, ..
}) => {
assert_eq!(max_spawn_depth, ceiling);
}
other => panic!("expected subagent runtime update, got {other:?}"),
}
}
#[test]
fn config_command_subagents_status_shows_raw_and_resolved_values() {
let temp_root = env::temp_dir().join(format!(
"codewhale-subagents-status-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
fs::write(
&config_path,
r#"
[subagents]
enabled = true
max_concurrent = 2
max_depth = 0
launch_concurrency = 5
api_timeout_secs = 0
heartbeat_timeout_secs = 1
"#,
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path);
let result = config_command(&mut app, Some("subagents status"));
let msg = result.message.unwrap();
assert!(!result.is_error);
assert!(msg.contains("Sub-agents: disabled (subagents.max_depth=0)"));
assert!(msg.contains("Active provider: deepseek"));
assert!(
msg.contains("subagents.max_concurrent = 2 (resolved global 2; active provider 2)")
);
assert!(
msg.contains("subagents.launch_concurrency = 5 (resolved global 2; active provider 2)")
);
assert!(
msg.contains(
"subagents.api_timeout_secs = 0 (resolved global 120; active provider 120)"
)
);
assert!(msg.contains(
"subagents.heartbeat_timeout_secs = 1 (resolved global 150; active provider 150)"
));
assert!(msg.contains("subagents.providers.deepseek = inherits global"));
}
#[test]
fn config_command_audit_lists_editability_and_current_values() {
let temp_root = env::temp_dir().join(format!(
"codewhale-config-audit-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
fs::write(
&config_path,
r#"
base_url = "https://api.from-config.local/v1"
instructions = ["~/global.md"]
[subagents]
enabled = false
max_concurrent = 4
"#,
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
app.approval_mode = ApprovalMode::Never;
app.stream_chunk_timeout_secs = 45;
let result = config_command(&mut app, Some("audit"));
let msg = result.message.unwrap();
assert!(!result.is_error);
assert!(msg.contains("Config editability audit"));
assert!(msg.contains(&format!("Config path: {}", config_path.display())));
assert!(msg.contains("effective_permissions | Never | runtime"));
assert!(msg.contains("permission_posture | (unset) | TUI settings"));
assert!(msg.contains("approval_policy | (unset) | persisted config"));
assert!(msg.contains("stream_chunk_timeout_secs | 45 | runtime+persisted"));
assert!(msg.contains("subagents.enabled | false | runtime+persisted"));
assert!(msg.contains("subagents.max_concurrent | 4 | runtime+persisted"));
assert!(msg.contains("base_url | https://api.from-config.local/v1 | persisted restart"));
assert!(msg.contains("providers.<active>.context_window | (unset) | persisted restart"));
assert!(msg.contains("effective_context_window |"), "{msg}");
assert!(msg.contains("| runtime | /config context_window"), "{msg}");
assert!(msg.contains("instructions | configured | file-only restart"));
assert!(msg.contains("network | unset | file-only"));
app.mode = AppMode::Plan;
let plan_msg = config_command(&mut app, Some("audit"))
.message
.expect("Plan audit message");
assert!(
plan_msg.contains("effective_permissions | Read Only | runtime"),
"{plan_msg}"
);
}
#[test]
fn config_context_window_query_shows_override_and_effective_source() {
let temp_root = env::temp_dir().join(format!(
"codewhale-context-window-query-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
fs::write(
&config_path,
r#"
provider = "moonshot"
[providers.moonshot]
model = "kimi-k3"
context_window = 262144
"#,
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path);
app.api_provider = ApiProvider::Moonshot;
app.model = "kimi-k3".to_string();
app.active_route_limits = Some(codewhale_config::route::RouteLimits {
context_tokens: Some(262_144),
..Default::default()
});
app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured;
let result = config_command(&mut app, Some("context_window"));
let message = result.message.expect("context window message");
assert!(!result.is_error, "{message}");
assert!(
message.contains("262144 (effective 262144 from configured)"),
"{message}"
);
}
#[test]
fn config_command_base_url_without_save_requires_save() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = config_command(&mut app, Some("base_url https://example.internal.local/v1"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(
msg.contains("base_url must be saved with --save"),
"got {msg}"
);
}
#[test]
fn config_command_base_url_reads_current_value_from_config() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-show-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(
&config_path,
"base_url = \"https://api.from-config.local/v1\"\n",
)
.unwrap();
let mut app = create_test_app();
let result = config_command(&mut app, Some("base_url"));
let msg = result.message.unwrap();
assert_eq!(msg, "base_url = https://api.from-config.local/v1");
}
#[test]
fn config_command_base_url_reads_current_value_from_app_config_path() {
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-app-config-path-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
fs::write(
&config_path,
"base_url = \"https://api.from-app-path.local/v1\"\n",
)
.unwrap();
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("base_url"));
let msg = result.message.unwrap();
assert_eq!(msg, "base_url = https://api.from-app-path.local/v1");
}
#[test]
fn config_command_base_url_save_persists_to_app_config_path() {
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-base-url-save-app-path-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(
&mut app,
Some("base_url https://example.session.local/v1 --save"),
);
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert_eq!(
msg,
format!(
"base_url = https://example.session.local/v1 (saved to {})",
config_path.display()
)
);
assert!(saved.contains("base_url = \"https://example.session.local/v1\""));
}
#[test]
fn config_command_stream_chunk_timeout_session_query_uses_live_value() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90"));
assert!(!result.is_error);
assert_eq!(app.stream_chunk_timeout_secs, 90);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(90))
));
let query = config_command(&mut app, Some("stream_chunk_timeout_secs"));
assert_eq!(
query.message.as_deref(),
Some("stream_chunk_timeout_secs = 90")
);
}
#[test]
fn config_command_stream_chunk_timeout_save_persists_tui_key() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-stream-timeout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert_eq!(
msg,
format!(
"stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)",
config_path.display()
)
);
assert!(saved.contains("[tui]"));
assert!(saved.contains("stream_chunk_timeout_secs = 120"));
assert_eq!(app.stream_chunk_timeout_secs, 120);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(120))
));
}
#[test]
fn config_command_stream_chunk_timeout_rejects_invalid_input() {
let _lock = lock_test_env();
let mut app = create_test_app();
let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc"));
assert!(text.is_error);
assert!(
text.message
.unwrap()
.contains("stream_chunk_timeout_secs must be a whole number")
);
let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601"));
assert!(high.is_error);
assert!(
high.message
.unwrap()
.contains("stream_chunk_timeout_secs must be 0 or 1..=3600")
);
}
#[test]
fn config_command_stream_chunk_timeout_zero_reports_effective_default() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0"));
assert!(!result.is_error);
assert_eq!(
app.stream_chunk_timeout_secs,
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
);
assert_eq!(
result.message.as_deref(),
Some(
"stream_chunk_timeout_secs = 0 (default 900) (session only; affects subsequent turns in this session)"
)
);
assert!(matches!(
result.action,
Some(AppAction::UpdateStreamChunkTimeout(
DEFAULT_STREAM_CHUNK_TIMEOUT_SECS
))
));
}
#[test]
fn config_command_provider_url_token_plan_persists_provider_base_url() {
let temp_root = env::temp_dir().join(format!(
"codewhale-provider-url-save-app-path-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.api_provider = ApiProvider::XiaomiMimo;
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("provider_url token-plan --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert_eq!(
msg,
format!(
"provider_url = {} for xiaomi-mimo (saved to {}; restart required)",
DEFAULT_XIAOMI_MIMO_BASE_URL,
config_path.display()
)
);
assert!(saved.contains("[providers.xiaomi_mimo]"));
assert!(saved.contains(&format!("base_url = \"{DEFAULT_XIAOMI_MIMO_BASE_URL}\"")));
}
#[test]
fn config_command_provider_url_without_save_requires_save() {
let _lock = lock_test_env();
let mut app = create_test_app();
app.api_provider = ApiProvider::XiaomiMimo;
let result = config_command(&mut app, Some("provider_url token-plan"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(
msg.contains("provider_url must be saved with --save"),
"got {msg}"
);
}
#[test]
fn theme_command_accepts_grayscale_arg() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-theme-command-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = theme(&mut app, Some("grayscale"));
assert_eq!(result.message.unwrap(), "theme = grayscale (saved)");
assert_eq!(app.theme_id, crate::palette::ThemeId::Grayscale);
assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale);
assert!(app.needs_redraw);
}
#[test]
fn explicit_default_background_override_survives_theme_preview() {
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-background-override-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
let _guard = EnvGuard::new(&temp_root);
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"theme = \"solarized-light\"\nbackground_color = \"#fdf6e3\"\n",
)
.expect("seed settings");
let mut app = create_test_app();
let explicit_base3 = ratatui::style::Color::Rgb(0xfd, 0xf6, 0xe3);
assert_eq!(app.background_color_override, Some(explicit_base3));
let result = set_config_value(&mut app, "theme", "dark", false);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(app.theme_id, crate::palette::ThemeId::Whale);
assert_eq!(app.background_color_override, Some(explicit_base3));
assert_eq!(app.ui_theme.surface_bg, explicit_base3);
assert!(
crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(),
"the explicit surface must retain ombre when previewing another theme"
);
}
#[test]
fn session_only_background_override_survives_theme_preview() {
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-session-background-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
let _guard = EnvGuard::new(&temp_root);
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"theme = \"solarized-light\"\n",
)
.expect("seed settings");
let mut app = create_test_app();
let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26);
let background = set_config_value(&mut app, "background_color", "#1a1b26", false);
assert!(!background.is_error, "{:?}", background.message);
assert_eq!(app.background_color_override, Some(custom));
let preview = set_config_value(&mut app, "theme", "dark", false);
assert!(!preview.is_error, "{:?}", preview.message);
assert_eq!(app.background_color_override, Some(custom));
assert_eq!(app.ui_theme.surface_bg, custom);
let solarized_preview = set_config_value(&mut app, "theme", "solarized-light", false);
assert!(
!solarized_preview.is_error,
"{:?}",
solarized_preview.message
);
assert_eq!(app.background_color_override, Some(custom));
assert_eq!(app.ui_theme.surface_bg, custom);
assert!(crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some());
let saved_theme = set_config_value(&mut app, "theme", "dark", true);
assert!(!saved_theme.is_error, "{:?}", saved_theme.message);
assert_eq!(app.background_color_override, Some(custom));
assert_eq!(app.ui_theme.surface_bg, custom);
let persisted = Settings::load_persisted().expect("persisted settings");
assert_eq!(persisted.theme, "dark");
assert_eq!(
persisted.background_color, None,
"saving a theme must not persist the session-only background"
);
}
#[test]
fn set_theme_save_updates_live_app_and_persists() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-theme-save-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let mut app = create_test_app();
let result = config_command(&mut app, Some("theme grayscale --save"));
let msg = result.message.unwrap();
assert_eq!(msg, "theme = grayscale (saved)");
assert_eq!(app.ui_theme.mode, crate::palette::PaletteMode::Grayscale);
let settings_path = Settings::path().unwrap();
let saved = fs::read_to_string(settings_path).unwrap();
assert!(saved.contains("theme = \"grayscale\""));
}
#[test]
fn unrelated_save_does_not_persist_no_animations_runtime_overlay() {
let temp_root = env::temp_dir().join(format!(
"codewhale-no-animations-save-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
let _guard = EnvGuard::new(&temp_root);
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"low_motion = false\nfancy_animations = true\ntheme = \"system\"\n",
)
.expect("seed settings");
unsafe {
env::set_var("NO_ANIMATIONS", "1");
}
let mut app = create_test_app();
assert!(app.low_motion, "runtime overlay should reduce motion");
assert!(
!app.fancy_animations,
"runtime overlay should disable ocean animations"
);
let result = set_config_value(&mut app, "theme", "grayscale", true);
assert!(!result.is_error, "{:?}", result.message);
let saved = Settings::load_persisted().expect("persisted settings");
assert_eq!(saved.theme, "grayscale");
assert!(
!saved.low_motion,
"NO_ANIMATIONS must not become a saved preference"
);
assert!(
saved.fancy_animations,
"NO_ANIMATIONS must not overwrite the saved animation preference"
);
assert!(app.low_motion);
assert!(!app.fancy_animations);
}
#[test]
fn preset_save_does_not_persist_runtime_environment_overlays() {
let temp_root = env::temp_dir().join(format!(
"codewhale-preset-env-overlay-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir");
let _guard = EnvGuard::new(&temp_root);
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"low_motion = false\nfancy_animations = true\nsynchronized_output = \"auto\"\n",
)
.expect("seed settings");
unsafe {
env::set_var("NO_ANIMATIONS", "1");
env::set_var("PTYXIS_VERSION", "50.0");
}
let mut app = create_test_app();
let result = config_command(&mut app, Some("preset calm --save"));
assert!(!result.is_error, "{:?}", result.message);
let saved = Settings::load_persisted().expect("persisted settings");
assert!(saved.low_motion, "calm preset should save reduced motion");
assert!(
!saved.fancy_animations,
"calm preset should save static ocean chrome"
);
assert_eq!(
saved.synchronized_output, "auto",
"Ptyxis runtime override must not leak into a preset save"
);
}
#[test]
fn config_approval_mode_valid_values() {
let dir = tempfile::tempdir().expect("isolated config dir");
let mut app = create_test_app();
app.config_path = Some(dir.path().join("config.toml"));
let result = config_command(&mut app, Some("approval_mode auto"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
let result = config_command(&mut app, Some("approval_mode suggest"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
let result = config_command(&mut app, Some("approval_mode never"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn config_approval_mode_save_persists_top_level_policy() {
let temp_root = env::temp_dir().join(format!(
"codewhale-approval-policy-save-test-{}",
std::process::id()
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
let result = config_command(&mut app, Some("approval_mode suggest --save"));
let msg = result.message.unwrap();
let saved = fs::read_to_string(&config_path).unwrap();
assert!(!result.is_error);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert_eq!(
msg,
format!(
"approval_mode = Ask (saved to {} as approval_policy = \"on-request\")",
config_path.display()
)
);
assert!(saved.contains("approval_policy = \"on-request\""));
let loaded = Config::load(Some(config_path.clone()), None).unwrap();
assert_eq!(loaded.approval_policy.as_deref(), Some("on-request"));
let mut restarted = create_test_app_with_config(&loaded);
restarted.config_path = Some(config_path.clone());
assert!(restarted.approval_policy_locked());
assert!(!restarted.approval_policy_requirements_managed());
let changed = config_command(&mut restarted, Some("approval_mode auto --save"));
assert!(!changed.is_error, "{:?}", changed.message);
assert_eq!(
changed.action,
Some(AppAction::ApprovalPolicyPersisted {
policy: Some("auto".to_string())
})
);
assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
let reloaded = Config::load(Some(config_path), None).unwrap();
assert_eq!(reloaded.approval_policy.as_deref(), Some("auto"));
}
#[test]
fn config_approval_policy_can_return_to_saved_tui_permission_default() {
let temp_root = env::temp_dir().join(format!(
"codewhale-approval-policy-tui-default-test-{}",
std::process::id()
));
fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
fs::write(&config_path, "# keep\napproval_policy = \"auto\"\n").unwrap();
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"permission_posture = \"full-access\"\n",
)
.unwrap();
let loaded = Config::load(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&loaded);
app.config_path = Some(config_path.clone());
let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(!app.approval_policy_locked());
assert_eq!(
result.action,
Some(AppAction::ApprovalPolicyPersisted { policy: None })
);
let saved = fs::read_to_string(config_path).unwrap();
assert!(saved.contains("# keep"));
assert!(!saved.contains("approval_policy"));
}
#[test]
fn config_approval_policy_full_access_adopts_tui_posture_and_releases_root_override() {
let temp_root = env::temp_dir().join(format!(
"codewhale-approval-policy-full-access-test-{}",
std::process::id()
));
fs::create_dir_all(temp_root.join(".deepseek")).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join("custom-config.toml");
fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n").unwrap();
fs::write(
temp_root.join(".deepseek").join("settings.toml"),
"permission_posture = \"ask\"\n",
)
.unwrap();
let loaded = Config::load(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&loaded);
app.config_path = Some(config_path.clone());
app.mark_approval_policy_locked();
assert!(app.approval_policy_locked());
let result = set_config_value(&mut app, "approval_policy", "full-access", true);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(!app.approval_policy_locked());
assert_eq!(
result.action,
Some(AppAction::ApprovalPolicyPersisted { policy: None })
);
let saved_config = fs::read_to_string(config_path).unwrap();
assert!(saved_config.contains("# keep"));
assert!(!saved_config.contains("approval_policy"));
let saved_settings = Settings::load_persisted().expect("saved TUI settings");
assert_eq!(
saved_settings.permission_posture.as_deref(),
Some("full-access")
);
}
#[test]
fn config_approval_mode_invalid_value() {
let dir = tempfile::tempdir().expect("isolated config dir");
let mut app = create_test_app();
app.config_path = Some(dir.path().join("config.toml"));
let result = config_command(&mut app, Some("approval_mode invalid"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Invalid approval_mode"));
}
#[test]
fn config_without_save_flag() {
let _lock = lock_test_env();
let mut app = create_test_app();
let result = config_command(&mut app, Some("auto_compact true"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(session only"));
}
#[test]
fn config_threshold_enables_and_updates_live_auto_compaction() {
let _lock = lock_test_env();
let mut app = create_test_app();
app.auto_compact = false;
app.auto_compact_user_configured = false;
let result = config_command(&mut app, Some("auto_compact_threshold_percent 65"));
assert!(!result.is_error, "{:?}", result.message);
assert!(app.auto_compact);
assert!(app.auto_compact_user_configured);
assert_eq!(app.auto_compact_threshold_percent, 65.0);
assert_eq!(
app.compact_threshold,
crate::route_budget::compaction_threshold_for_route_at_percent(
app.api_provider,
app.effective_model_for_budget(),
app.active_route_limits,
65.0,
)
);
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn config_composer_border_updates_live_app() {
let _lock = lock_test_env();
let mut app = create_test_app();
app.composer_border = true;
let result = config_command(&mut app, Some("composer_border false"));
assert!(result.message.is_some());
assert!(!app.composer_border);
assert!(app.needs_redraw);
}
#[test]
fn test_trust_on_enables_flag() {
let mut app = create_test_app();
app.trust_mode = false;
let result = trust(&mut app, Some("on"));
let msg = result.message.expect("message");
assert!(msg.contains("Workspace trust mode enabled"));
assert!(app.trust_mode);
}
#[test]
fn test_trust_status_default_lists_state() {
let mut app = create_test_app();
let result = trust(&mut app, None);
let msg = result.message.expect("status message");
assert!(msg.contains("Workspace trust mode"));
}
#[test]
fn test_trust_add_requires_path() {
let mut app = create_test_app();
let result = trust(&mut app, Some("add"));
let msg = result.message.expect("error message");
assert!(msg.starts_with("Error:"), "got {msg:?}");
}
#[test]
fn test_logout_clears_api_key_state() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-logout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
let mut app = create_test_app();
let result = logout(&mut app);
assert!(result.message.is_some());
assert_eq!(app.onboarding, OnboardingState::ApiKey);
assert!(app.onboarding_needs_api_key);
assert!(app.api_key_input.is_empty());
assert_eq!(app.api_key_cursor, 0);
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("api_key"));
}
#[test]
fn logout_clears_only_exact_named_custom_provider_key() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-custom-logout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(
&config_path,
"[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n",
)
.unwrap();
let mut app = create_test_app();
app.set_provider_identity(ApiProvider::Custom, "custom-a");
let result = logout(&mut app);
assert!(result.message.is_some());
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("a-key"), "{updated}");
assert!(updated.contains("b-key"), "{updated}");
}
#[test]
fn named_custom_provider_url_write_fails_closed() {
let mut app = create_test_app();
app.set_provider_identity(ApiProvider::Custom, "custom-a");
let result = config_command(
&mut app,
Some("provider_url http://127.0.0.1:18181/v1 --save"),
);
let message = result.message.expect("error message");
assert!(
message.contains("named [providers.<name>] table"),
"{message}"
);
}
}