use super::CommandResult;
use crate::config::{
ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS,
DEFAULT_XIAOMI_MIMO_BASE_URL, MAX_STREAM_CHUNK_TIMEOUT_SECS, MIN_STREAM_CHUNK_TIMEOUT_SECS,
XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key,
normalize_model_name_for_provider,
};
use crate::config_persistence::{
persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key,
persist_tui_integer_key,
};
use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::localization::resolve_locale;
use crate::settings::Settings;
use crate::tui::app::{
App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode,
};
use crate::tui::approval::ApprovalMode;
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);
}
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 show_single_setting(app: &App, key: &str) -> CommandResult {
let key = key.to_lowercase();
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",
}
}
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.api_provider.as_str().to_string()),
"approval_mode" | "approval" => Some(app.approval_mode.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.api_provider.as_str().to_string());
config
}
Err(err) => {
return CommandResult::error(format!("Failed to load config: {err}"));
}
};
Some(config.deepseek_base_url())
}
"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())
}
"show_tool_details" | "tool_details" => Some(
if app.show_tool_details {
"true"
} else {
"false"
}
.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()),
"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())
}
"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().to_string()),
"prefer_external_pdftotext" | "external_pdftotext" | "pdftotext" => Settings::load()
.ok()
.map(|settings| settings.prefer_external_pdftotext.to_string()),
_ => {
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 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::Auto
} else {
SidebarFocus::Hidden
}
}
[value] => match value.to_ascii_lowercase().as_str() {
"on" | "show" | "visible" => SidebarFocus::Auto,
"off" | "hide" | "hidden" | "closed" | "none" => SidebarFocus::Hidden,
"auto" => SidebarFocus::Auto,
"work" | "plan" | "todos" => SidebarFocus::Work,
"tasks" => SidebarFocus::Tasks,
"agents" | "subagents" | "sub-agents" => SidebarFocus::Agents,
"context" | "session" => SidebarFocus::Context,
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|auto|work|tasks|agents|context] [--save]",
);
}
},
_ => {
return CommandResult::error(
"Usage: /sidebar [on|off|auto|work|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(target).to_string();
CommandResult::message(message)
}
fn sidebar_status_message(focus: SidebarFocus) -> &'static str {
if focus == SidebarFocus::Hidden {
"Sidebar is hidden"
} else {
"Sidebar is visible"
}
}
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 stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
if raw == 0 {
format!("0 (default {resolved})")
} else {
resolved.to_string()
}
}
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
let key = key.to_lowercase();
match key.as_str() {
"model" => {
if value.trim().eq_ignore_ascii_case("auto") {
app.set_model_selection("auto".to_string());
app.reasoning_effort = ReasoningEffort::Auto;
app.last_effective_reasoning_effort = None;
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
return CommandResult::with_message_and_action(
"model = auto (auto-select model and thinking per turn)".to_string(),
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}'. Expected a DeepSeek model ID. Common models: {}",
COMMON_DEEPSEEK_MODELS.join(", ")
));
};
app.set_model_selection(model.clone());
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = 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" => {
let mode = ApprovalMode::from_config_value(value);
return match mode {
Some(m) => {
app.approval_mode = m;
CommandResult::message(format!("approval_mode = {}", m.label()))
}
None => CommandResult::error(
"Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never/deny",
),
};
}
"allow_shell" | "shell" | "exec_shell" => {
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
Err(err) => return CommandResult::error(err),
};
app.allow_shell = enabled;
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()
};
let mode_hint = if enabled {
" Agent mode will expose shell on the next turn with approval gating. YOLO 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");
}
app.mcp_config_path = PathBuf::from(expand_tilde(value));
app.mcp_restart_required = true;
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 {}; restart required for MCP tool pool)",
app.mcp_config_path.display(),
path.display()
),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
format!(
"mcp_config_path = {} (session only; restart required for MCP tool pool)",
app.mcp_config_path.display()
)
};
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() {
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 let Err(e) = settings.set(&key, value) {
return CommandResult::error(format!("{e}"));
}
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()));
}
"calm_mode" | "calm" => {
app.calm_mode = settings.calm_mode;
app.mark_history_updated();
}
"low_motion" | "motion" => {
app.low_motion = settings.low_motion;
app.needs_redraw = true;
}
"fancy_animations" | "fancy" | "animations" => {
app.fancy_animations = settings.fancy_animations;
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 = settings.synchronized_output_enabled();
app.needs_redraw = true;
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
app.mark_history_updated();
}
"show_tool_details" | "tool_details" => {
app.show_tool_details = settings.show_tool_details;
app.mark_history_updated();
}
"locale" | "language" => {
app.ui_locale = resolve_locale(&settings.locale);
app.mark_history_updated();
app.needs_redraw = true;
}
"theme" | "ui_theme" | "background_color" | "background" | "bg" => {
app.theme_id = crate::palette::ThemeId::from_name(&settings.theme)
.unwrap_or(crate::palette::ThemeId::System);
app.ui_theme = crate::palette::ui_theme_from_settings(
&settings.theme,
settings.background_color.as_deref(),
);
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.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.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.needs_redraw = true;
}
"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);
}
"max_history" | "history" => {
app.max_input_history = settings.max_input_history;
}
"default_model" => {
if let Some(ref model) = settings.default_model {
app.set_model_selection(model.clone());
if app.auto_model {
app.reasoning_effort = ReasoningEffort::Auto;
app.last_effective_reasoning_effort = None;
}
app.update_model_compaction_budget();
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
}
"reasoning_effort" | "effort" => {
app.reasoning_effort = if app.auto_model {
ReasoningEffort::Auto
} else {
settings
.reasoning_effort
.as_deref()
.map_or_else(ReasoningEffort::default, ReasoningEffort::from_setting)
};
app.last_effective_reasoning_effort = None;
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;
}
_ => {}
}
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
.clone()
.unwrap_or_else(|| "config/default".to_string()),
"composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(),
_ => value.to_string(),
};
let message = if persist {
if let Err(e) = settings.save() {
return CommandResult::error(format!("Failed to save: {e}"));
}
format!("{key} = {display_value} (saved)")
} else {
format!("{key} = {display_value} (session only, add --save to persist)")
};
CommandResult {
message: Some(message),
action,
is_error: false,
}
}
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 parse_mode_arg(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 [agent|plan|yolo|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) {
if app.set_mode(mode) {
(
format!("Switched to {} mode.", mode_display_name(mode)),
true,
)
} else {
(
format!("Already in {} mode.", mode_display_name(mode)),
false,
)
}
}
fn parse_mode_arg(arg: &str) -> Option<AppMode> {
match arg.trim().to_ascii_lowercase().as_str() {
"agent" | "1" => Some(AppMode::Agent),
"plan" | "2" => Some(AppMode::Plan),
"yolo" | "3" => Some(AppMode::Yolo),
_ => None,
}
}
fn mode_display_name(mode: AppMode) -> &'static str {
match mode {
AppMode::Agent => "Agent",
AppMode::Plan => "Plan",
AppMode::Yolo => "YOLO",
}
}
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(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 slop ledger: {e}")),
};
match arg {
"" => CommandResult::message(ledger.summary()),
"query" | "q" => {
if ledger.is_empty() {
return CommandResult::message("Slop 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 /slop action '{arg}'. Use /slop, /slop query, or /slop 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) = dirs::home_dir()
{
return home.join(rest).to_string_lossy().into_owned();
} else if raw == "~"
&& let Some(home) = dirs::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.api_provider.as_str();
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>,
_lock: std::sync::MutexGuard<'static, ()>,
}
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");
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);
}
Self {
home: home_prev,
userprofile: userprofile_prev,
codewhale_config_path: codewhale_config_prev,
deepseek_config_path: deepseek_config_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");
}
}
}
}
fn create_test_app() -> App {
let options = TuiOptions {
model: "test-model".to_string(),
workspace: PathBuf::from("."),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
initial_input: None,
};
let mut app = App::new(options, &Config::default());
app.model = "test-model".to_string();
app.auto_model = false;
app.api_provider = crate::config::ApiProvider::Deepseek;
app.model_ids_passthrough = false;
app
}
#[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 YOLO 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::Auto);
assert_eq!(app.mode, AppMode::Yolo);
}
#[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("3"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Yolo)));
assert_eq!(app.mode, AppMode::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 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_auto_enables_auto_thinking() {
let mut app = create_test_app();
app.reasoning_effort = 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::Auto);
assert!(app.last_effective_model.is_none());
assert!(app.last_effective_reasoning_effort.is_none());
}
#[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 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 anthropic"));
assert!(result.is_error);
let msg = result.message.unwrap();
assert!(msg.contains("Unknown provider 'anthropic'"));
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("Agent mode"));
assert!(msg.contains("approval gating"));
assert!(msg.contains("next turn"));
assert!(msg.contains("YOLO also enables shell and auto-approves"));
}
#[test]
fn config_command_allow_shell_save_persists_root_boolean() {
let temp_root = env::temp_dir().join(format!(
"codewhale-allow-shell-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("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 {}). Agent mode will expose shell on the next turn with approval gating. YOLO 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_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 300) (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 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 config_approval_mode_valid_values() {
let mut app = create_test_app();
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_invalid_value() {
let mut app = create_test_app();
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_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"));
}
}