diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 6a5775382..9798a7531 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -82,6 +82,7 @@ tar = "0.4" flate2 = "1.1" sha2 = "0.10" + [dev-dependencies] wiremock = "0.6" pretty_assertions = "1.4" diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs deleted file mode 100644 index 36d5e2fd0..000000000 --- a/crates/tui/src/commands/config.rs +++ /dev/null @@ -1,2726 +0,0 @@ -//! Config commands: config, settings, mode switches, trust, logout - -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use super::CommandResult; -use crate::client::DeepSeekClient; -use crate::config::{ - ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL, - XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, effective_home_dir, - expand_path, normalize_model_name_for_provider, -}; -use crate::config_ui::{ConfigUiMode, parse_mode}; -use crate::llm_client::LlmClient; -use crate::localization::resolve_locale; -use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; -use crate::settings::Settings; -use crate::tui::app::{ - App, AppAction, AppMode, OnboardingState, ReasoningEffort, SidebarFocus, VimMode, -}; -use crate::tui::approval::ApprovalMode; -use anyhow::Result; - -/// Open the interactive config editor. -/// -/// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action), -/// preserving the v0.8.4 behaviour. `/config tui` opens the new -/// schemaui-driven TUI editor; `/config web` launches the web editor (only -/// available in builds compiled with the `web` feature). -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) -} - -/// Dispatch `/config` with optional args. -/// -/// - `/config` (no args) — opens the schemaui-driven TUI editor. -/// - `/config tui` / `/config web` / `/config native` — open a specific -/// editor mode (web requires the `web` build feature). -/// - `/config ` — shows the current value of a setting. -/// - `/config ` — sets a runtime value (session only, add --save to persist). -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 { - // Single arg: editor-mode shortcut OR show-value request. - let token = parts[0]; - if matches!( - token.to_ascii_lowercase().as_str(), - "tui" | "web" | "native" - ) { - return show_config(app, Some(token)); - } - // `/config ` — show current value - show_single_setting(app, token) - } else { - // `/config [--save|-s]` — set value, optionally persist - 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) - } -} - -/// Show the current value of a single setting. -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()) - } - "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()), - "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." - )), - } -} - -/// Show persistent 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}")), - } -} - -/// Open the `/statusline` multi-select picker for configuring footer items. -pub fn status_line(_app: &mut App) -> CommandResult { - CommandResult::action(AppAction::OpenStatusPicker) -} - -/// Toggle whether the live transcript renders full thinking detail. -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." - }) -} - -/// Persist `tui.status_items` to `~/.codewhale/config.toml` without disturbing -/// the rest of the file. We round-trip through `toml::Value` so any keys we -/// don't know about (provider blocks, MCP, etc.) survive the write -/// untouched. -/// -/// Returns the path written so the caller can surface it in a status toast. -pub fn persist_status_items(items: &[crate::config::StatusItem]) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(None)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let tui_entry = table - .entry("tui".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); - let tui_table = tui_entry - .as_table_mut() - .context("`tui` section in config.toml must be a table")?; - let array = items - .iter() - .map(|item| toml::Value::String(item.key().to_string())) - .collect::>(); - tui_table.insert("status_items".to_string(), toml::Value::Array(array)); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -pub fn persist_root_string_key( - config_path: Option<&Path>, - key: &str, - value: &str, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::String(value.to_string())); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_root_bool_key( - config_path: Option<&Path>, - key: &str, - value: bool, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - table.insert(key.to_string(), toml::Value::Boolean(value)); - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn persist_provider_base_url_key( - config_path: Option<&Path>, - provider: ApiProvider, - value: &str, -) -> anyhow::Result { - use anyhow::Context; - use std::fs; - - let path = config_toml_path(config_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("failed to create config directory {}", parent.display()))?; - } - - let mut doc: toml::Value = if path.exists() { - let raw = fs::read_to_string(&path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - toml::from_str(&raw) - .with_context(|| format!("failed to parse config at {}", path.display()))? - } else { - toml::Value::Table(toml::value::Table::new()) - }; - let table = doc - .as_table_mut() - .context("config.toml root must be a table")?; - let providers = table - .entry("providers".to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .context("`providers` must be a table")?; - let provider_key = provider_base_url_table_key(provider)?; - let entry = providers - .entry(provider_key.to_string()) - .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) - .as_table_mut() - .with_context(|| format!("`providers.{provider_key}` must be a table"))?; - entry.insert( - "base_url".to_string(), - toml::Value::String(value.to_string()), - ); - - let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; - fs::write(&path, body) - .with_context(|| format!("failed to write config at {}", path.display()))?; - Ok(path) -} - -fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { - match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN => { - anyhow::bail!("DeepSeek uses the root base_url setting") - } - ApiProvider::NvidiaNim => Ok("nvidia_nim"), - ApiProvider::Openai => Ok("openai"), - ApiProvider::Atlascloud => Ok("atlascloud"), - ApiProvider::WanjieArk => Ok("wanjie_ark"), - ApiProvider::Volcengine => Ok("volcengine"), - ApiProvider::Openrouter => Ok("openrouter"), - ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), - ApiProvider::Novita => Ok("novita"), - ApiProvider::Fireworks => Ok("fireworks"), - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), - ApiProvider::Arcee => Ok("arcee"), - ApiProvider::Huggingface => Ok("huggingface"), - ApiProvider::Moonshot => Ok("moonshot"), - ApiProvider::Sglang => Ok("sglang"), - ApiProvider::Vllm => Ok("vllm"), - ApiProvider::Ollama => Ok("ollama"), - } -} - -fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result { - 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 { - 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." - )), - } -} - -/// Resolve the path to `~/.codewhale/config.toml` (or -/// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we -/// never write to a different file than the one we read. -pub(super) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result { - use anyhow::Context; - if let Some(path) = config_path { - return Ok(expand_path(path.to_string_lossy().as_ref())); - } - if let Ok(env) = std::env::var("CODEWHALE_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { - let trimmed = env.trim(); - if !trimmed.is_empty() { - return Ok(PathBuf::from(trimmed)); - } - } - let home = - effective_home_dir().context("failed to resolve home directory for config.toml path")?; - let primary = home.join(".codewhale").join("config.toml"); - if primary.exists() { - return Ok(primary); - } - let legacy = home.join(".deepseek").join("config.toml"); - if legacy.exists() { - return Ok(legacy); - } - Ok(primary) -} - -/// Modify a setting at runtime -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" => { - // Support "/model auto" — auto-select model based on request complexity - 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()), - ); - } - // Clear auto mode when a specific model is set - 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.", - ); - } - _ => {} - } - - 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(); - } - "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, - } -} - -/// Modify a setting at runtime -#[allow(dead_code)] -pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult { - let Some(args) = args else { - let available = Settings::available_settings() - .iter() - .map(|(k, d)| format!(" {k}: {d}")) - .collect::>() - .join("\n"); - return CommandResult::message(format!( - "Usage: /set \n\n\ - Available settings:\n{available}\n\n\ - Session-only settings:\n \ - model: Current model\n \ - approval_mode: auto | suggest | never\n\n\ - Add --save to persist to settings file." - )); - }; - - let parts: Vec<&str> = args.splitn(2, ' ').collect(); - if parts.len() < 2 { - return CommandResult::error("Usage: /set "); - } - - let key = parts[0].to_lowercase(); - let (value, should_save) = if parts[1].ends_with(" --save") { - (parts[1].trim_end_matches(" --save").trim(), true) - } else { - (parts[1].trim(), false) - }; - - set_config_value(app, &key, value, should_save) -} - -/// Select the TUI operating mode. -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 { - 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", - } -} - -/// `/theme [name]` — with no argument, open the interactive picker (arrow -/// keys, live preview, Enter to persist, Esc to revert). With an argument, -/// route through `set_config_value("theme", ...)` so the apply + save flow is -/// shared with `/config`. -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), - } -} - -/// `/slop [query|export]` — inspect or export the slop ledger (#2127). -/// With no arguments, prints a summary. `query` shows filtered results; -/// `export` outputs the full ledger as Markdown. -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." - )), - } -} - -/// Manage workspace-level trust and the per-path allowlist. -/// -/// Subcommands: -/// - `/trust` – show current state and trusted external paths -/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks) -/// - `/trust off` – disable workspace-level trust mode -/// - `/trust add ` – add a directory to the allowlist (#29) -/// - `/trust remove ` (alias `rm`) – remove a path from the allowlist -/// - `/trust list` – list trusted external paths for this workspace -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 ` 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 `, or `/trust remove `." - )), - } -} - -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 ` 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 . 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 "); - } - 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() -} - -/// Auto-select a model based on request complexity. -/// -/// Short messages (<100 chars) → Flash (fast & cheap). -/// Long messages (>500 chars) → Pro (powerful reasoning). -/// Messages with complex keywords → Pro. -/// Default → Flash (cost savings). -pub fn auto_model_heuristic(input: &str, _current_model: &str) -> String { - auto_model_heuristic_with_bias(input, _current_model, false) -} - -/// `auto_model_heuristic` parameterised by the `[auto] cost_saving` opt-in -/// (#1207). When `cost_saving` is `true` the keyword set drops the borderline -/// triggers (`implement`, `analyze`) and the long-message length threshold -/// goes from 500 to 1000 — both shifts let "looks involved but might be a -/// one-liner" requests stay on Flash unless they actually look agentic. -pub fn auto_model_heuristic_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> String { - auto_model_heuristic_selection_with_bias(input, _current_model, cost_saving).model -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutoModelHeuristicConfidence { - Decisive, - Ambiguous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutoModelHeuristicSelection { - model: String, - confidence: AutoModelHeuristicConfidence, -} - -fn auto_model_heuristic_selection_with_bias( - input: &str, - _current_model: &str, - cost_saving: bool, -) -> AutoModelHeuristicSelection { - let len = input.chars().count(); - let lower = input.to_lowercase(); - let borderline_pro_keywords: &[&str] = &[ - "implement", - "analyze", - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - "\u{5be6}\u{73fe}", // 實現 - ]; - let strong_match = COMPLEX_KEYWORDS - .iter() - .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); - let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); - let pro_match = strong_match || (!cost_saving && borderline_match); - if pro_match { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Short messages → Flash - if len < 100 { - return AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Long complex requests → Pro. Cost-saving raises the threshold so that - // long-but-routine requests (pasted logs, CSV-style data) don't escalate. - let long_threshold = if cost_saving { 1_000 } else { 500 }; - if len > long_threshold { - return AutoModelHeuristicSelection { - model: "deepseek-v4-pro".to_string(), - confidence: AutoModelHeuristicConfidence::Decisive, - }; - } - // Grey-zone default branch: Flash is the deterministic fallback, but the - // Flash router can still add value here because there was no strong local - // signal. - AutoModelHeuristicSelection { - model: "deepseek-v4-flash".to_string(), - confidence: AutoModelHeuristicConfidence::Ambiguous, - } -} - -/// Keywords that escalate `auto`-mode model selection to -/// `deepseek-v4-pro`. The Latin entries are lowercase (the caller -/// lowercases the message); CJK has no case so the literal form -/// matches as-is. -/// -/// Without the CJK entries, a Chinese-speaking user typing -/// "帮我重构这个模块" or "审计安全漏洞" silently fell through to the -/// short/long-message threshold and usually landed on Flash even -/// for tasks that obviously need Pro-grade reasoning. -const COMPLEX_KEYWORDS: &[&str] = &[ - // English (unchanged from the original list). - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - "implement", - "analyze", - // Simplified Chinese. - "\u{91cd}\u{6784}", // 重构 - "\u{67b6}\u{6784}", // 架构 - "\u{8bbe}\u{8ba1}", // 设计 - "\u{8c03}\u{8bd5}", // 调试 - "\u{5b89}\u{5168}", // 安全 - "\u{5ba1}\u{67e5}", // 审查 - "\u{5ba1}\u{8ba1}", // 审计 - "\u{8fc1}\u{79fb}", // 迁移 - "\u{4f18}\u{5316}", // 优化 - "\u{91cd}\u{5199}", // 重写 - "\u{5b9e}\u{73b0}", // 实现 - "\u{5206}\u{6790}", // 分析 - // Traditional Chinese variants where they differ. - "\u{91cd}\u{69cb}", // 重構 - "\u{67b6}\u{69cb}", // 架構 - "\u{8a2d}\u{8a08}", // 設計 - "\u{8abf}\u{8a66}", // 調試 - "\u{5be9}\u{67e5}", // 審查 - "\u{5be9}\u{8a08}", // 審計 - "\u{9077}\u{79fb}", // 遷移 - "\u{512a}\u{5316}", // 優化 - "\u{91cd}\u{5beb}", // 重寫 - "\u{5be6}\u{73fe}", // 實現 -]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteRecommendation { - pub model: String, - pub reasoning_effort: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AutoRouteSource { - FlashRouter, - Heuristic, -} - -impl AutoRouteSource { - #[must_use] - pub fn label(self) -> &'static str { - match self { - AutoRouteSource::FlashRouter => "flash-router", - AutoRouteSource::Heuristic => "heuristic", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AutoRouteSelection { - pub model: String, - pub reasoning_effort: Option, - pub source: AutoRouteSource, -} - -pub const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\ -You are the codewhale auto-routing classifier. Return only compact JSON: \ -{\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \ -Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \ -Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \ -tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \ -Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \ -agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work."; - -/// Bias appended to the auto-router's system prompt when the user opts in to -/// `[auto] cost_saving = true` (#1207). Reverses the default tie-breaker for -/// genuinely ambiguous requests so Pro is reserved for tasks that clearly -/// require it; ordinary tweaks, config edits, and short reads stay on Flash. -pub const AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM: &str = "\ -\n\nCost-saving mode is ON. Prefer deepseek-v4-flash for any request that is \ -not unmistakably agentic, multi-step, architecture/design, security review, \ -debugging, or otherwise clearly out of Flash's capability. Resolve ambiguous \ -cases in favour of deepseek-v4-flash, not deepseek-v4-pro."; - -/// Parse the Flash router's JSON-only response. -/// -/// The runtime treats classifier output as untrusted: only known V4 model IDs -/// and supported reasoning tiers are accepted. Anything else falls back to the -/// deterministic heuristic. -pub fn parse_auto_route_recommendation(raw: &str) -> Option { - let json = extract_first_json_object(raw)?; - let value: serde_json::Value = serde_json::from_str(json).ok()?; - let model = value.get("model").and_then(serde_json::Value::as_str)?; - let model = normalize_auto_route_model(model)?; - let reasoning_effort = value - .get("thinking") - .or_else(|| value.get("reasoning_effort")) - .or_else(|| value.get("effort")) - .and_then(serde_json::Value::as_str) - .and_then(parse_auto_route_reasoning_effort); - - Some(AutoRouteRecommendation { - model: model.to_string(), - reasoning_effort, - }) -} - -fn extract_first_json_object(raw: &str) -> Option<&str> { - let start = raw.find('{')?; - let end = raw.rfind('}')?; - (end >= start).then_some(&raw[start..=end]) -} - -fn normalize_auto_route_model(model: &str) -> Option<&'static str> { - match model.trim().to_ascii_lowercase().as_str() { - "deepseek-v4-pro" | "v4-pro" | "pro" => Some("deepseek-v4-pro"), - "deepseek-v4-flash" | "v4-flash" | "flash" => Some("deepseek-v4-flash"), - _ => None, - } -} - -fn parse_auto_route_reasoning_effort(effort: &str) -> Option { - match effort.trim().to_ascii_lowercase().as_str() { - "off" | "disabled" | "none" | "false" => Some(ReasoningEffort::Off), - "low" | "minimal" | "medium" | "mid" => Some(ReasoningEffort::High), - "high" => Some(ReasoningEffort::High), - "max" | "maximum" | "xhigh" => Some(ReasoningEffort::Max), - _ => None, - } -} - -#[must_use] -pub fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort { - match effort { - ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High, - other => other, - } -} - -pub async fn resolve_auto_route_with_flash( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> AutoRouteSelection { - let cost_saving = config.auto_cost_saving(); - let heuristic = - auto_model_heuristic_selection_with_bias(latest_request, selected_model_mode, cost_saving); - if heuristic.confidence == AutoModelHeuristicConfidence::Decisive { - return auto_route_from_heuristic(latest_request, heuristic); - } - - match auto_route_flash_recommendation( - config, - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ) - .await - { - Ok(Some(recommendation)) => AutoRouteSelection { - model: recommendation.model, - reasoning_effort: recommendation.reasoning_effort, - source: AutoRouteSource::FlashRouter, - }, - Ok(None) | Err(_) => auto_route_from_heuristic(latest_request, heuristic), - } -} - -fn auto_route_from_heuristic( - latest_request: &str, - heuristic: AutoModelHeuristicSelection, -) -> AutoRouteSelection { - AutoRouteSelection { - model: heuristic.model, - reasoning_effort: Some(normalize_auto_route_effort(crate::auto_reasoning::select( - false, - latest_request, - ))), - source: AutoRouteSource::Heuristic, - } -} - -async fn auto_route_flash_recommendation( - config: &crate::config::Config, - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> Result> { - if cfg!(test) { - return Ok(None); - } - - let client = DeepSeekClient::new(config)?; - let mut router_system = AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(); - if config.auto_cost_saving() { - router_system.push_str(AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM); - } - let request = MessageRequest { - model: "deepseek-v4-flash".to_string(), - messages: vec![Message { - role: "user".to_string(), - content: vec![ContentBlock::Text { - text: auto_route_prompt( - latest_request, - recent_context, - selected_model_mode, - selected_thinking_mode, - ), - cache_control: None, - }], - }], - max_tokens: 96, - system: Some(SystemPrompt::Text(router_system)), - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("off".to_string()), - stream: Some(false), - temperature: Some(0.0), - top_p: None, - }; - - let response = - tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??; - Ok(parse_auto_route_recommendation(&message_response_text( - &response, - ))) -} - -fn auto_route_prompt( - latest_request: &str, - recent_context: &str, - selected_model_mode: &str, - selected_thinking_mode: &str, -) -> String { - format!( - "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.", - selected_model_mode, - selected_thinking_mode, - if recent_context.trim().is_empty() { - "No prior context." - } else { - recent_context - }, - truncate_for_auto_router(latest_request, 4_000) - ) -} - -fn message_response_text(response: &MessageResponse) -> String { - let mut out = String::new(); - for block in &response.content { - match block { - ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => { - append_router_text(&mut out, text); - } - ContentBlock::Thinking { thinking } => { - append_router_text(&mut out, thinking); - } - ContentBlock::ToolUse { name, .. } => { - append_router_text(&mut out, &format!("[tool call: {name}]")); - } - _ => {} - } - } - out -} - -fn append_router_text(out: &mut String, text: &str) { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(text); -} - -fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { - let mut chars = text.chars(); - let truncated: String = chars.by_ref().take(max_chars).collect(); - if chars.next().is_some() { - format!("{truncated}...") - } else { - truncated - } -} - -/// Toggle LSP diagnostics on/off or show status. -/// -/// - `/lsp on` — enable inline LSP diagnostics -/// - `/lsp off` — disable inline LSP diagnostics -/// - `/lsp status` — show whether diagnostics are currently enabled -pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - // Access lsp_manager config through the App's engine handle - 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`." - )), - } -} - -/// Logout - clear all saved API keys and return to onboarding. -/// This is NOT provider-scoped — it clears keys for every saved provider. -/// For single-provider key replacement, use -/// `codewhale auth clear --provider ` and -/// `codewhale auth set --provider `. -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 ` 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, - userprofile: Option, - codewhale_config_path: Option, - deepseek_config_path: Option, - _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"); - - // Safety: test-only environment mutation guarded by process-wide mutex. - 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() { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::set_var("HOME", value); - } - } else { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::remove_var("HOME"); - } - } - - if let Some(value) = self.userprofile.take() { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::set_var("USERPROFILE", value); - } - } else { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::remove_var("USERPROFILE"); - } - } - - if let Some(value) = self.codewhale_config_path.take() { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::set_var("CODEWHALE_CONFIG_PATH", value); - } - } else { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::remove_var("CODEWHALE_CONFIG_PATH"); - } - } - - if let Some(value) = self.deepseek_config_path.take() { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - env::set_var("DEEPSEEK_CONFIG_PATH", value); - } - } else { - // Safety: test-only environment mutation guarded by a global mutex. - 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::new folds in saved TUI settings from the developer machine. - // Pin command tests back to DeepSeek semantics so model aliases are - // not normalized through a provider selected in an interactive run. - 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(); - // Switch to Agent first to guarantee a clean starting state regardless of - // user settings on the host machine. - 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); - // Settings should load (may use defaults if file doesn't exist) - assert!(result.message.is_some()); - } - - #[test] - fn test_set_without_args_shows_usage() { - let mut app = create_test_app(); - let result = set_config(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - assert!(msg.contains("Available settings:")); - } - - #[test] - fn test_set_model_updates_app_state() { - let mut app = create_test_app(); - let _old_model = app.model.clone(); - let result = set_config(&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 test_set_model_auto_enables_auto_thinking() { - let mut app = create_test_app(); - app.reasoning_effort = ReasoningEffort::Off; - - let result = set_config(&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 test_set_model_accepts_future_deepseek_model_id() { - let mut app = create_test_app(); - let result = set_config(&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 test_set_model_with_save_flag() { - let mut app = create_test_app(); - let _result = set_config(&mut app, Some("model deepseek-v4-flash --save")); - // Note: This test may fail in environments where settings can't be saved - // The important thing is that the model is updated - assert_eq!(app.model, "deepseek-v4-flash"); - } - - #[test] - fn auto_model_heuristic_chinese_keywords_route_to_pro() { - // Without these keywords, a Chinese user typing - // "帮我重构这个模块" (37 chars in chars().count() terms after - // the leading helper text) fell through to the short-message - // Flash branch even though the intent is obviously Pro-tier. - for msg in [ - "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 帮我重构这个模块 - "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", // 设计数据库架构 - "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", // 调试崩溃问题 - "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", // 审计安全漏洞 - "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", // 迁移到新框架 - "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", // 优化性能瓶颈 - "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", // 分析这段代码 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { - for msg in [ - "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", // 請重構此模組 - "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", // 架構設計 - "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 - "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", // 審計漏洞 - "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", // 遷移到新架構 - "\u{512a}\u{5316}\u{6027}\u{80fd}", // 優化性能 - "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", // 重寫代碼 - "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", // 實現新功能 - ] { - assert_eq!( - auto_model_heuristic(msg, "auto"), - "deepseek-v4-pro", - "expected Pro for `{msg}`", - ); - } - } - - #[test] - fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { - // Sanity: a short non-keyword Chinese message still falls - // through to the cost-saving Flash branch. - // "你好" (2 chars) — well under the 100-char Flash floor. - assert_eq!( - auto_model_heuristic("\u{4f60}\u{597d}", "auto"), - "deepseek-v4-flash", - ); - } - - #[test] - fn auto_heuristic_selection_marks_short_and_complex_routes_decisive() { - let short = auto_model_heuristic_selection_with_bias("yes", "auto", false); - assert_eq!(short.model, "deepseek-v4-flash"); - assert_eq!( - short.confidence, - AutoModelHeuristicConfidence::Decisive, - "trivial replies should skip the Flash router" - ); - - let complex = auto_model_heuristic_selection_with_bias( - "Please review the auth migration", - "auto", - false, - ); - assert_eq!(complex.model, "deepseek-v4-pro"); - assert_eq!( - complex.confidence, - AutoModelHeuristicConfidence::Decisive, - "strong complexity keywords should skip the Flash router" - ); - } - - #[test] - fn auto_heuristic_selection_leaves_default_branch_ambiguous_for_router() { - let request = - "Please update the configuration notes so each option has a clearer label. ".repeat(3); - assert!( - (100..500).contains(&request.chars().count()), - "test request must stay in the default grey zone" - ); - - let selection = auto_model_heuristic_selection_with_bias(&request, "auto", false); - assert_eq!(selection.model, "deepseek-v4-flash"); - assert_eq!( - selection.confidence, - AutoModelHeuristicConfidence::Ambiguous, - "only the grey-zone default branch should invoke the Flash router" - ); - } - - #[test] - fn auto_route_recommendation_parses_strict_json() { - let rec = - parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#) - .expect("valid router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max)); - } - - #[test] - fn auto_route_recommendation_accepts_wrapped_json_aliases() { - let rec = - parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#) - .expect("wrapped router response should parse"); - - assert_eq!(rec.model, "deepseek-v4-flash"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off)); - } - - #[test] - fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() { - let rec = parse_auto_route_recommendation( - r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#, - ) - .expect("medium should parse for back-compat"); - - assert_eq!(rec.model, "deepseek-v4-pro"); - assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High)); - } - - #[test] - fn auto_route_recommendation_rejects_unknown_model() { - assert!( - parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,) - .is_none() - ); - } - - #[test] - fn auto_heuristic_default_routes_implement_to_pro() { - // Default (no cost-saving): "implement" is one of the borderline - // keywords that escalates to Pro. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), - "deepseek-v4-pro" - ); - } - - #[test] - fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { - // Cost-saving: "implement" / "analyze" are no longer enough to escalate. - assert_eq!( - auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), - "deepseek-v4-flash" - ); - assert_eq!( - auto_model_heuristic_with_bias("analyze this snippet", "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { - // Cost-saving must NOT swallow obviously Pro-grade work. - for kw in [ - "refactor", - "architecture", - "design", - "debug", - "security", - "review", - "audit", - "migrate", - "optimize", - "rewrite", - ] { - let req = format!("Please {kw} this module"); - assert_eq!( - auto_model_heuristic_with_bias(&req, "auto", true), - "deepseek-v4-pro", - "expected Pro for strong keyword `{kw}` even in cost-saving mode" - ); - } - } - - #[test] - fn auto_heuristic_cost_saving_raises_long_message_threshold() { - // 600-char request is "long" by default (>500) → Pro, - // but stays Flash under cost-saving (threshold 1000). - let body = "filler sentence. ".repeat(40); // ~680 chars - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", false), - "deepseek-v4-pro" - ); - assert_eq!( - auto_model_heuristic_with_bias(&body, "auto", true), - "deepseek-v4-flash" - ); - } - - #[test] - fn config_auto_cost_saving_defaults_to_false() { - let cfg = crate::config::Config::default(); - assert!(!cfg.auto_cost_saving()); - } - - #[test] - fn config_auto_cost_saving_reads_table() { - let cfg = crate::config::Config { - auto: Some(crate::config::AutoConfig { - cost_saving: Some(true), - }), - ..Default::default() - }; - assert!(cfg.auto_cost_saving()); - } - - #[test] - fn test_set_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 = set_config(&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 = 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_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 = set_config(&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 test_set_approval_mode_valid_values() { - let mut app = create_test_app(); - // Test auto - let result = set_config(&mut app, Some("approval_mode auto")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Auto); - - // Test suggest - let result = set_config(&mut app, Some("approval_mode suggest")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Suggest); - - // Test never - let result = set_config(&mut app, Some("approval_mode never")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Never); - } - - #[test] - fn test_set_approval_mode_invalid_value() { - let mut app = create_test_app(); - let result = set_config(&mut app, Some("approval_mode invalid")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Invalid approval_mode")); - } - - #[test] - fn test_set_without_save_flag() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let result = set_config(&mut app, Some("auto_compact true")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("(session only")); - } - - #[test] - fn test_set_composer_border_updates_live_app() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - app.composer_border = true; - - let result = set_config(&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(); - // Normalize trust state regardless of user settings on the host machine. - 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 test_set_invalid_setting() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let _result = set_config(&mut app, Some("nonexistent value")); - // Should either error or handle as session setting - // The current implementation tries to set it in Settings - // which may succeed or fail depending on Settings implementation - } - - #[test] - fn test_set_key_without_value() { - let mut app = create_test_app(); - let result = set_config(&mut app, Some("model")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Usage: /set")); - } - - #[test] - fn persist_status_items_writes_tui_section_to_config_toml() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-persist-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let items = vec![ - crate::config::StatusItem::Mode, - crate::config::StatusItem::Model, - crate::config::StatusItem::Cost, - ]; - - let path = persist_status_items(&items).expect("persist should succeed"); - let body = fs::read_to_string(&path).expect("written file should be readable"); - assert!(body.contains("[tui]"), "expected [tui] section in {body}"); - assert!( - body.contains("status_items"), - "expected status_items key in {body}" - ); - assert!(body.contains("\"mode\""), "expected mode key in {body}"); - assert!(body.contains("\"cost\""), "expected cost key in {body}"); - } - - #[test] - fn config_toml_path_uses_codewhale_home_for_fresh_installs() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-fresh-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!( - config_toml_path(None).unwrap(), - temp_root.join(".codewhale").join("config.toml") - ); - } - - #[test] - fn config_toml_path_preserves_legacy_config_when_it_exists() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-legacy-{}-{}", - std::process::id(), - nanos - )); - let legacy_config = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); - fs::write(&legacy_config, "").unwrap(); - let _guard = EnvGuard::new(&temp_root); - - unsafe { - env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - - assert_eq!(config_toml_path(None).unwrap(), legacy_config); - } - - #[test] - fn config_toml_path_prefers_codewhale_env_over_legacy_env() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-config-path-env-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let preferred = temp_root.join("preferred.toml"); - let legacy = temp_root.join("legacy.toml"); - - unsafe { - env::set_var("CODEWHALE_CONFIG_PATH", &preferred); - env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); - } - - assert_eq!(config_toml_path(None).unwrap(), preferred); - } - - #[test] - fn persist_status_items_preserves_existing_unrelated_keys() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-statusline-preserve-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - // Seed the config with a sentinel key the picker MUST NOT clobber. - fs::write( - &path, - "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", - ) - .unwrap(); - - let written = persist_status_items(&[crate::config::StatusItem::Mode]) - .expect("persist should succeed"); - let body = fs::read_to_string(&written).expect("written file should be readable"); - assert!( - body.contains("api_key = \"sentinel-key\""), - "round-trip lost api_key: {body}" - ); - assert!( - body.contains("model = \"deepseek-v4-pro\""), - "round-trip lost model: {body}" - ); - assert!( - body.contains("status_items"), - "expected status_items in {body}" - ); - } -} diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs deleted file mode 100644 index c8f32bddc..000000000 --- a/crates/tui/src/commands/core.rs +++ /dev/null @@ -1,1084 +0,0 @@ -//! Core commands: help, clear, exit, model - -use std::fmt::Write; -use std::path::PathBuf; - -use crate::config::{ - ApiProvider, COMMON_DEEPSEEK_MODELS, normalize_custom_model_id, - normalize_model_name_for_provider, provider_passes_model_through, -}; -use crate::localization::{MessageId, tr}; -use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort}; -use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; - -use super::CommandResult; - -/// Show help information -pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { - if let Some(topic) = topic { - // Show help for specific command - if let Some(cmd) = super::get_command_info(topic) { - let mut help = format!( - "{}\n\n {}\n\n {} {}", - cmd.name, - cmd.description_for(app.ui_locale), - tr(app.ui_locale, MessageId::HelpUsageLabel), - cmd.usage - ); - if !cmd.aliases.is_empty() { - let _ = write!( - help, - "\n {} {}", - tr(app.ui_locale, MessageId::HelpAliasesLabel), - cmd.aliases.join(", ") - ); - } - return CommandResult::message(help); - } - return CommandResult::error( - tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), - ); - } - - // Show help overlay - if app.view_stack.top_kind() != Some(ModalKind::Help) { - app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); - } - CommandResult::ok() -} - -/// Clear conversation history -pub fn clear(app: &mut App) -> CommandResult { - let todos_cleared = reset_conversation_state(app); - app.current_session_id = None; - let locale = app.ui_locale; - let message = if todos_cleared { - tr(locale, MessageId::ClearConversation).to_string() - } else { - tr(locale, MessageId::ClearConversationBusy).to_string() - }; - CommandResult::with_message_and_action( - message, - AppAction::SyncSession { - session_id: None, - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) -} - -/// Reset the active conversation without choosing the next session id. -pub(crate) fn reset_conversation_state(app: &mut App) -> bool { - app.clear_history(); - app.mark_history_updated(); - app.api_messages.clear(); - app.system_prompt = None; - app.viewport.transcript_selection.clear(); - app.queued_messages.clear(); - app.queued_draft = None; - app.session.total_tokens = 0; - app.session.total_conversation_tokens = 0; - app.session.reset_token_breakdown(); - app.session.session_cost = 0.0; - app.session.session_cost_cny = 0.0; - app.session.subagent_cost = 0.0; - app.session.subagent_cost_cny = 0.0; - app.session.subagent_cost_event_seqs.clear(); - app.session.displayed_cost_high_water = 0.0; - app.session.displayed_cost_high_water_cny = 0.0; - let todos_cleared = app.clear_todos(); - app.tool_log.clear(); - app.tool_cells.clear(); - app.tool_details_by_cell.clear(); - app.exploring_entries.clear(); - app.ignored_tool_calls.clear(); - app.pending_tool_uses.clear(); - app.last_exec_wait_command = None; - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - app.session.last_prompt_cache_hit_tokens = None; - app.session.last_prompt_cache_miss_tokens = None; - app.session.last_reasoning_replay_tokens = None; - app.session.turn_cache_history.clear(); - app.session.last_cache_inspection = None; - app.session.last_warmup_key = None; - app.session.last_tool_catalog = None; - app.session.last_base_url = None; - todos_cleared -} - -/// Exit the application -pub fn exit() -> CommandResult { - CommandResult::action(AppAction::Quit) -} - -/// Switch or view current model. With no argument, open the two-pane -/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable -/// way to flip both knobs without memorising the docs. -pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { - if let Some(name) = model_name { - if name.trim().eq_ignore_ascii_case("auto") { - let old_model = app.model_display_label(); - let model_changed = !app.auto_model || app.model != "auto"; - app.auto_model = true; - app.model = "auto".to_string(); - app.last_effective_model = None; - app.reasoning_effort = ReasoningEffort::Auto; - app.last_effective_reasoning_effort = None; - app.update_model_compaction_budget(); - if model_changed { - app.clear_model_scoped_telemetry(); - } else { - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - } - app.provider_models - .insert(app.api_provider.as_str().to_string(), "auto".to_string()); - let persist_warning = - provider_model_selection_persist_warning(app.api_provider, "auto"); - let mut message = tr(app.ui_locale, MessageId::ModelChanged) - .replace("{old}", &old_model) - .replace("{new}", "auto"); - if let Some(warning) = persist_warning { - message.push_str(&warning); - } - return CommandResult::with_message_and_action( - message, - AppAction::UpdateCompaction(app.compaction_config()), - ); - } - let model_id = if app.accepts_custom_model_ids() { - let Some(model_id) = normalize_custom_model_id(name) else { - return CommandResult::error(format!( - "Invalid model '{name}'. Expected a non-empty model ID." - )); - }; - model_id - } else { - let Some(model_id) = normalize_model_name_for_provider(app.api_provider, name) else { - if let Some((provider, model_id)) = saved_provider_model_match(app, name) { - return CommandResult::with_message_and_action( - format!( - "Switching provider to {} for model {model_id}.", - provider.as_str() - ), - AppAction::SwitchProvider { - provider, - model: Some(model_id), - }, - ); - } - return CommandResult::error(format!( - "Invalid model '{name}'. Expected auto, a model for the active provider, or a saved provider model. Common DeepSeek models: {}", - COMMON_DEEPSEEK_MODELS.join(", ") - )); - }; - model_id - }; - let old_model = app.model_display_label(); - let model_changed = app.auto_model || app.model != model_id; - app.auto_model = false; - app.model = model_id.clone(); - app.last_effective_model = None; - app.update_model_compaction_budget(); - if model_changed { - app.clear_model_scoped_telemetry(); - } else { - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - } - app.provider_models - .insert(app.api_provider.as_str().to_string(), model_id.clone()); - let persist_warning = provider_model_selection_persist_warning(app.api_provider, &model_id); - let mut message = tr(app.ui_locale, MessageId::ModelChanged) - .replace("{old}", &old_model) - .replace("{new}", &model_id); - if let Some(warning) = persist_warning { - message.push_str(&warning); - } - CommandResult::with_message_and_action( - message, - AppAction::UpdateCompaction(app.compaction_config()), - ) - } else { - CommandResult::action(AppAction::OpenModelPicker) - } -} - -fn provider_model_selection_persist_warning(provider: ApiProvider, model: &str) -> Option { - crate::settings::Settings::persist_provider_model_selection(provider, model) - .err() - .map(|err| format!(" (not persisted: {err})")) -} - -fn saved_provider_model_match(app: &App, name: &str) -> Option<(ApiProvider, String)> { - let requested = normalize_custom_model_id(name)?; - let mut saved = app - .provider_models - .iter() - .filter_map(|(provider_name, model)| { - let provider = ApiProvider::parse(provider_name)?; - (provider != app.api_provider).then_some((provider, model.as_str())) - }) - .collect::>(); - saved.sort_by_key(|(provider, _)| provider.as_str()); - - for (provider, saved_model) in saved { - let Some(saved_model) = normalize_model_for_provider_selection(provider, saved_model) - else { - continue; - }; - let requested_model = normalize_model_for_provider_selection(provider, &requested) - .unwrap_or_else(|| requested.clone()); - if saved_model.eq_ignore_ascii_case(&requested_model) - || saved_model.eq_ignore_ascii_case(&requested) - { - return Some((provider, saved_model)); - } - } - - None -} - -fn normalize_model_for_provider_selection(provider: ApiProvider, model: &str) -> Option { - if provider_passes_model_through(provider) { - normalize_custom_model_id(model) - } else { - normalize_model_name_for_provider(provider, model) - } -} - -/// Fetch and list available models from the configured API endpoint. -pub fn models(_app: &mut App) -> CommandResult { - CommandResult::action(AppAction::FetchModels) -} - -/// List sub-agent status from the engine -pub fn subagents(app: &mut App) -> CommandResult { - if app.view_stack.top_kind() != Some(ModalKind::SubAgents) { - let agents = subagent_view_agents(app, &app.subagent_cache); - app.view_stack.push(SubAgentsView::new(agents)); - } - app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string()); - CommandResult::action(AppAction::ListSubAgents) -} - -/// Switch to a configured profile. -pub fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult { - let profile_name = match arg { - Some(name) if !name.trim().is_empty() => name.trim().to_string(), - _ => { - return CommandResult::error( - "Usage: /profile \n\nSwitch to a named config profile. Profiles are defined in ~/.codewhale/config.toml under [profiles] sections.", - ); - } - }; - CommandResult::with_message_and_action( - format!("Switching to profile '{profile_name}'..."), - AppAction::SwitchProfile { - profile: profile_name, - }, - ) -} - -pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { - let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { - return CommandResult::message(format!("Current workspace: {}", app.workspace.display())); - }; - - let expanded = match expand_workspace_path(raw_path) { - Ok(path) => path, - Err(message) => return CommandResult::error(message), - }; - let candidate = if expanded.is_absolute() { - expanded - } else { - app.workspace.join(expanded) - }; - - if !candidate.exists() { - return CommandResult::error(format!("Workspace does not exist: {}", candidate.display())); - } - if !candidate.is_dir() { - return CommandResult::error(format!( - "Workspace is not a directory: {}", - candidate.display() - )); - } - - let workspace = candidate.canonicalize().unwrap_or(candidate); - CommandResult::with_message_and_action( - format!("Switching workspace to {}...", workspace.display()), - AppAction::SwitchWorkspace { workspace }, - ) -} - -fn expand_workspace_path(path: &str) -> Result { - if path == "~" { - return dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string()); - } - if let Some(rest) = path.strip_prefix("~/") { - let home = - dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string())?; - return Ok(home.join(rest)); - } - Ok(PathBuf::from(path)) -} - -/// Show `DeepSeek` dashboard and docs links -pub fn deepseek_links(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - CommandResult::message(format!( - "{}\n\ -─────────────────────────────\n\ -{} https://platform.deepseek.com\n\ -{} https://platform.deepseek.com/docs\n\n\ -{}", - tr(locale, MessageId::LinksTitle), - tr(locale, MessageId::LinksDashboard), - tr(locale, MessageId::LinksDocs), - tr(locale, MessageId::LinksTip), - )) -} - -/// Show home dashboard with stats and quick actions -pub fn home_dashboard(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - let mut stats = String::new(); - - // Basic info - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle)); - let _ = writeln!(stats, "============================================"); - - // Model & mode - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeModel), - app.model - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeMode), - app.mode.label() - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeWorkspace), - app.workspace.display() - ); - - // Session stats - let history_count = app.history.len(); - let total_tokens = app.session.total_conversation_tokens; - let queued_messages = app.queued_messages.len(); - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeHistory), - history_count - ); - let _ = writeln!( - stats, - "{} {} (session)", - tr(locale, MessageId::HomeTokens), - total_tokens - ); - if queued_messages > 0 { - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeQueued), - queued_messages - ); - } - - // Sub-agents - let subagent_count = app.subagent_cache.len(); - if subagent_count > 0 { - let _ = writeln!( - stats, - "{} {} active", - tr(locale, MessageId::HomeSubagents), - subagent_count - ); - } - - // Active skill - if let Some(skill) = &app.active_skill { - let _ = writeln!( - stats, - "{} {} (active)", - tr(locale, MessageId::HomeSkill), - skill - ); - } - - // Quick actions section - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions)); - let _ = writeln!(stats, "--------------------------------------------"); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp)); - - // Mode-specific tips - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips)); - let _ = writeln!(stats, "--------------------------------------------"); - match app.mode { - AppMode::Agent => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip)); - } - AppMode::Yolo => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeCaution)); - } - AppMode::Plan => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip)); - } - } - - CommandResult::message(stats) -} - -/// Toggle output translation to the current system language on/off. -/// -/// When enabled, the model is instructed to respond in the current locale and an -/// interception layer translates any remaining English output before it -/// reaches the user. -pub fn translate(app: &mut App) -> CommandResult { - app.translation_enabled = !app.translation_enabled; - let locale = app.ui_locale; - if app.translation_enabled { - CommandResult::message(tr(locale, MessageId::CmdTranslateOn)) - } else { - CommandResult::message(tr(locale, MessageId::CmdTranslateOff)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::client::PromptInspection; - use crate::config::Config; - use crate::models::Message; - use crate::tui::app::{App, AppMode, TuiOptions, TurnCacheRecord}; - use crate::tui::history::HistoryCell; - use std::ffi::OsString; - use std::path::PathBuf; - use std::time::Instant; - use tempfile::{TempDir, tempdir}; - - struct SettingsPathGuard { - _tmp: TempDir, - previous: Option, - _lock: std::sync::MutexGuard<'static, ()>, - } - - impl SettingsPathGuard { - fn new() -> Self { - let lock = crate::test_support::lock_test_env(); - let tmp = TempDir::new().expect("settings tempdir"); - let config_path = tmp.path().join(".deepseek").join("config.toml"); - std::fs::create_dir_all(config_path.parent().expect("config parent")) - .expect("config dir"); - let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); - } - Self { - _tmp: tmp, - previous, - _lock: lock, - } - } - } - - impl Drop for SettingsPathGuard { - fn drop(&mut self) { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); - } else { - std::env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - } - } - } - - fn create_test_app() -> App { - let options = TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: PathBuf::from("/tmp/test-workspace"), - 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("/tmp/test-skills"), - 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: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - let mut app = App::new(options, &Config::default()); - app.ui_locale = crate::localization::Locale::En; - app.api_provider = crate::config::ApiProvider::Deepseek; - app.model = "deepseek-v4-pro".to_string(); - app.auto_model = false; - app.model_ids_passthrough = false; - app - } - - #[test] - fn test_help_unknown_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("nonexistent")); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Unknown command")); - assert!(result.action.is_none()); - } - - #[test] - fn test_help_known_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("clear")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("clear")); - assert!(msg.contains("Clear conversation history")); - assert!(msg.contains("Usage: /clear")); - } - - #[test] - fn test_help_config_topic_uses_interactive_editor_text() { - let mut app = create_test_app(); - let result = help(&mut app, Some("config")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("config")); - assert!(msg.contains("Open interactive configuration editor")); - assert!(msg.contains("Usage: /config")); - } - - #[test] - fn test_help_links_topic_shows_aliases() { - let mut app = create_test_app(); - let result = help(&mut app, Some("links")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("links")); - assert!(msg.contains("Show DeepSeek dashboard and docs links")); - assert!(msg.contains("Usage: /links")); - assert!(msg.contains("Aliases: dashboard, api")); - } - - #[test] - fn test_help_memory_topic_shows_usage_and_description() { - let mut app = create_test_app(); - let result = help(&mut app, Some("memory")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("memory")); - assert!(msg.contains("persistent user-memory file")); - assert!(msg.contains("Usage: /memory [show|path|clear|edit|help]")); - } - - #[test] - fn test_help_pushes_overlay() { - let mut app = create_test_app(); - assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); - let result = help(&mut app, None); - assert_eq!(result.message, None); - assert_eq!(result.action, None); - assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); - } - - #[test] - fn test_help_does_not_duplicate_overlay() { - let mut app = create_test_app(); - help(&mut app, None); - let initial_kind = app.view_stack.top_kind(); - help(&mut app, None); - assert_eq!(app.view_stack.top_kind(), initial_kind); - } - - #[test] - fn test_clear_resets_all_state() { - let mut app = create_test_app(); - // Set up some state - app.history.push(HistoryCell::User { - content: "test".to_string(), - }); - app.api_messages.push(Message { - role: "user".to_string(), - content: vec![], - }); - app.session.total_conversation_tokens = 100; - app.tool_log.push("test".to_string()); - app.current_session_id = Some("existing-session".to_string()); - app.session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_call_big".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "existing-session".to_string(), - tool_call_id: "call-big".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 128, - preview: "tool output".to_string(), - storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), - }); - - let result = clear(&mut app); - assert!(result.message.is_some()); - assert!(app.history.is_empty()); - assert!(app.api_messages.is_empty()); - assert_eq!(app.session.total_conversation_tokens, 0); - assert!(app.tool_log.is_empty()); - assert!(app.tool_cells.is_empty()); - assert!(app.tool_details_by_cell.is_empty()); - assert!(app.session_artifacts.is_empty()); - assert!(app.current_session_id.is_none()); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - } - - #[test] - fn clear_resets_session_telemetry() { - let mut app = create_test_app(); - app.session.total_tokens = 234; - app.session.total_conversation_tokens = 123; - app.session.session_cost = 0.42; - app.session.session_cost_cny = 3.05; - app.session.subagent_cost = 0.11; - app.session.subagent_cost_cny = 0.80; - app.session.subagent_cost_event_seqs.insert(7); - app.session.displayed_cost_high_water = 0.53; - app.session.displayed_cost_high_water_cny = 3.85; - app.session.last_prompt_cache_hit_tokens = Some(70); - app.session.last_prompt_cache_miss_tokens = Some(30); - app.session.last_reasoning_replay_tokens = Some(12); - app.session.last_warmup_key = None; - app.session.last_tool_catalog = Some(Vec::new()); - app.session.last_base_url = Some("https://api.deepseek.com".to_string()); - app.session.last_cache_inspection = Some(PromptInspection { - base_static_prefix_hash: "base".to_string(), - full_request_prefix_hash: "full".to_string(), - tool_catalog_hash: String::new(), - layers: Vec::new(), - }); - app.push_turn_cache_record(TurnCacheRecord { - input_tokens: 100, - output_tokens: 25, - cache_hit_tokens: Some(70), - cache_miss_tokens: Some(30), - reasoning_replay_tokens: Some(12), - recorded_at: Instant::now(), - }); - - clear(&mut app); - - assert_eq!(app.session.total_tokens, 0); - assert_eq!(app.session.total_conversation_tokens, 0); - assert_eq!(app.session.session_cost, 0.0); - assert_eq!(app.session.session_cost_cny, 0.0); - assert_eq!(app.session.subagent_cost, 0.0); - assert_eq!(app.session.subagent_cost_cny, 0.0); - assert!(app.session.subagent_cost_event_seqs.is_empty()); - assert_eq!(app.session.displayed_cost_high_water, 0.0); - assert_eq!(app.session.displayed_cost_high_water_cny, 0.0); - assert_eq!(app.session.last_prompt_cache_hit_tokens, None); - assert_eq!(app.session.last_prompt_cache_miss_tokens, None); - assert_eq!(app.session.last_reasoning_replay_tokens, None); - assert!(app.session.turn_cache_history.is_empty()); - assert_eq!(app.session.last_cache_inspection, None); - assert_eq!(app.session.last_warmup_key, None); - assert_eq!(app.session.last_tool_catalog, None); - assert_eq!(app.session.last_base_url, None); - } - - #[test] - fn test_exit_returns_quit_action() { - let result = exit(); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::Quit))); - } - - #[test] - fn workspace_without_arg_shows_current_workspace() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, None); - let msg = result.message.expect("workspace should be shown"); - assert!(msg.contains("Current workspace:")); - assert!(msg.contains("/tmp/test-workspace")); - assert!(result.action.is_none()); - } - - #[test] - fn workspace_existing_absolute_dir_returns_switch_action() { - let mut app = create_test_app(); - let dir = tempdir().expect("temp dir"); - let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() - )); - } - - #[test] - fn workspace_relative_dir_resolves_from_current_workspace() { - let root = tempdir().expect("temp dir"); - let child = root.path().join("child"); - std::fs::create_dir(&child).expect("child dir"); - let mut app = create_test_app(); - app.workspace = root.path().to_path_buf(); - - let result = workspace_switch(&mut app, Some("child")); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() - )); - } - - #[test] - fn workspace_rejects_missing_path() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, Some("definitely-missing")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("does not exist")); - } - - #[test] - fn workspace_rejects_file_path() { - let root = tempdir().expect("temp dir"); - let file = root.path().join("file.txt"); - std::fs::write(&file, "not a directory").expect("test file"); - let mut app = create_test_app(); - - let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); - assert!(result.is_error); - assert!(result.message.unwrap().contains("not a directory")); - } - - #[test] - fn test_model_change_updates_state() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - let old_model = app.model.clone(); - let result = model(&mut app, Some("deepseek-v4-flash")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains(&old_model)); - assert!(msg.contains("deepseek-v4-flash")); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - assert_eq!(app.model, "deepseek-v4-flash"); - assert_eq!(app.session.last_prompt_tokens, None); - assert_eq!(app.session.last_completion_tokens, None); - } - - #[test] - fn model_command_persists_active_provider_model() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - - let result = model(&mut app, Some("deepseek-v4-flash")); - - assert!(result.message.is_some()); - assert_eq!( - app.provider_models.get("deepseek").map(String::as_str), - Some("deepseek-v4-flash") - ); - let settings = crate::settings::Settings::load().expect("load settings"); - assert_eq!(settings.default_provider.as_deref(), Some("deepseek")); - assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-flash")); - assert_eq!( - settings - .provider_models - .as_ref() - .and_then(|models| models.get("deepseek")) - .map(String::as_str), - Some("deepseek-v4-flash") - ); - } - - #[test] - fn model_switch_clears_turn_cache_history() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - // Keep the assertion independent of the developer's saved default model. - app.auto_model = false; - app.model = "deepseek-v4-pro".to_string(); - app.push_turn_cache_record(TurnCacheRecord { - input_tokens: 100, - output_tokens: 25, - cache_hit_tokens: Some(70), - cache_miss_tokens: Some(30), - reasoning_replay_tokens: Some(12), - recorded_at: Instant::now(), - }); - - let result = model(&mut app, Some("deepseek-v4-flash")); - - assert!(result.message.is_some()); - assert!(app.session.turn_cache_history.is_empty()); - } - - #[test] - fn model_reset_same_model_keeps_turn_cache_history() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - app.auto_model = false; - app.model = "deepseek-v4-pro".to_string(); - app.push_turn_cache_record(TurnCacheRecord { - input_tokens: 100, - output_tokens: 25, - cache_hit_tokens: Some(70), - cache_miss_tokens: Some(30), - reasoning_replay_tokens: Some(12), - recorded_at: Instant::now(), - }); - - let result = model(&mut app, Some("deepseek-v4-pro")); - - assert!(result.message.is_some()); - assert_eq!(app.session.turn_cache_history.len(), 1); - } - - #[test] - fn test_model_auto_enables_auto_thinking() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - app.reasoning_effort = ReasoningEffort::Off; - - let result = model(&mut app, Some("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 test_model_change_accepts_future_deepseek_model() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - let result = model(&mut app, Some("deepseek-v4")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("deepseek-v4")); - assert_eq!(app.model, "deepseek-v4"); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn test_model_change_accepts_custom_id_for_openai_compatible_provider() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - app.api_provider = crate::config::ApiProvider::Openai; - app.model_ids_passthrough = true; - - let result = model(&mut app, Some("opencode-go/glm-5.1")); - - assert!(result.message.is_some()); - assert_eq!(app.model, "opencode-go/glm-5.1"); - assert!(!app.auto_model); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn test_model_change_accepts_custom_id_for_custom_base_url() { - let _settings = SettingsPathGuard::new(); - let mut app = create_test_app(); - app.model_ids_passthrough = true; - - let result = model(&mut app, Some("opencode-go/kimi-k2.6")); - - assert!(result.message.is_some()); - assert_eq!(app.model, "opencode-go/kimi-k2.6"); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn test_model_change_rejects_invalid_model() { - let mut app = create_test_app(); - let result = model(&mut app, Some("gpt-4")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Invalid model")); - assert!(msg.contains("active provider")); - assert!(msg.contains("deepseek-v4-pro")); - assert!(msg.contains("deepseek-v4-flash")); - assert!(result.action.is_none()); - } - - #[test] - fn model_command_switches_to_saved_provider_model() { - let mut app = create_test_app(); - app.api_provider = crate::config::ApiProvider::Deepseek; - app.provider_models - .insert("moonshot".to_string(), "kimi-k2.6".to_string()); - - let result = model(&mut app, Some("kimi-k2.6")); - - match result.action { - Some(AppAction::SwitchProvider { provider, model }) => { - assert_eq!(provider, crate::config::ApiProvider::Moonshot); - assert_eq!(model.as_deref(), Some("kimi-k2.6")); - } - other => panic!("expected SwitchProvider action, got {other:?}"), - } - assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek); - assert_eq!(app.model, "deepseek-v4-pro"); - } - - #[test] - fn test_model_without_args_opens_picker() { - let mut app = create_test_app(); - let result = model(&mut app, None); - assert_eq!(result.message, None); - assert_eq!(result.action, Some(AppAction::OpenModelPicker)); - } - - #[test] - fn test_models_triggers_fetch_action() { - let mut app = create_test_app(); - let result = models(&mut app); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::FetchModels))); - } - - #[test] - fn test_subagents_pushes_view_and_sets_status() { - let mut app = create_test_app(); - let result = subagents(&mut app); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::ListSubAgents))); - assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents)); - assert_eq!( - app.status_message, - Some("Fetching sub-agent status...".to_string()) - ); - } - - #[test] - fn test_deepseek_links() { - let mut app = create_test_app(); - let result = deepseek_links(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("DeepSeek Links")); - assert!(msg.contains("https://platform.deepseek.com")); - assert!(result.action.is_none()); - } - - #[test] - fn test_home_dashboard_includes_all_sections() { - let mut app = create_test_app(); - app.session.total_conversation_tokens = 1234; - let result = home_dashboard(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("codewhale Home Dashboard")); - assert!(msg.contains("Model:")); - assert!(msg.contains("Mode:")); - assert!(msg.contains("Workspace:")); - assert!(msg.contains("History:")); - assert!(msg.contains("Tokens:")); - assert!(msg.contains("Quick Actions")); - assert!(msg.contains("Mode Tips")); - assert!(result.action.is_none()); - } - - #[test] - fn test_home_dashboard_shows_queued_when_present() { - let mut app = create_test_app(); - app.queued_messages - .push_back(crate::tui::app::QueuedMessage::new( - "test".to_string(), - None, - )); - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Queued:")); - } - - #[test] - fn test_home_dashboard_mode_tips_for_each_mode() { - let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan]; - for mode in modes { - let mut app = create_test_app(); - app.mode = mode; - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); - } - } - - #[test] - fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { - let mut app = create_test_app(); - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!(msg.contains("/links - Dashboard & API links")); - assert!(msg.contains("/config - Open interactive configuration editor")); - assert!( - !msg.lines() - .any(|line| line.trim_start().starts_with("/set ")) - ); - assert!(!msg.contains("/codewhale")); - } - - #[test] - fn home_dashboard_localizes_in_zh_hans() { - use crate::localization::Locale; - let mut app = create_test_app(); - app.ui_locale = Locale::ZhHans; - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!(msg.contains("主面板"), "missing zh-Hans title:\n{msg}"); - assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}"); - assert!( - msg.contains("快捷操作"), - "missing zh-Hans quick actions:\n{msg}" - ); - assert!( - msg.contains("模式提示"), - "missing zh-Hans mode tips:\n{msg}" - ); - } -} diff --git a/crates/tui/src/commands/groups/config/config/config_command.rs b/crates/tui/src/commands/groups/config/config/config_command.rs new file mode 100644 index 000000000..68c68a25c --- /dev/null +++ b/crates/tui/src/commands/groups/config/config/config_command.rs @@ -0,0 +1,22 @@ +//! Config command. + +use super::config_impl::config_command; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Config; +impl Command for Config { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "config", + aliases: &[], + usage: "/config [key] [value]", + description_id: MessageId::CmdConfigDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + config_command(app, args) + } +} diff --git a/crates/tui/src/commands/groups/config/config/config_impl.rs b/crates/tui/src/commands/groups/config/config/config_impl.rs new file mode 100644 index 000000000..85fd63e9b --- /dev/null +++ b/crates/tui/src/commands/groups/config/config/config_impl.rs @@ -0,0 +1,244 @@ +use crate::commands::CommandResult; +use crate::config::Config; +use crate::config_actions::set_config_value; +use crate::config_ui::{ConfigUiMode, parse_mode}; +use crate::settings::Settings; +use crate::tui::app::App; +use crate::tui::app::AppAction; + +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 { + // Single arg: editor-mode shortcut OR show-value request. + let token = parts[0]; + if matches!( + token.to_ascii_lowercase().as_str(), + "tui" | "web" | "native" + ) { + return show_config(app, Some(token)); + } + // `/config ` — show current value + show_single_setting(app, token) + } else { + // `/config [--save|-s]` — set value, optionally persist + 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) + } +} + +/// Open the interactive config editor. +/// +/// Bare `/config` opens the legacy Native modal (the `OpenConfigView` action), +/// preserving the v0.8.4 behaviour. `/config tui` opens the schemaui-driven TUI +/// editor; `/config web` launches the web editor when the build enables it. +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) +} + +/// Show the current value of a single setting. +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()) + } + "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()), + "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." + )), + } +} diff --git a/crates/tui/src/commands/groups/config/config/mod.rs b/crates/tui/src/commands/groups/config/config/mod.rs new file mode 100644 index 000000000..efee0c29d --- /dev/null +++ b/crates/tui/src/commands/groups/config/config/mod.rs @@ -0,0 +1,5 @@ +//! Config command. + +pub mod config_command; +pub mod config_impl; +pub use config_command::Config; diff --git a/crates/tui/src/commands/groups/config/logout/logout_command.rs b/crates/tui/src/commands/groups/config/logout/logout_command.rs new file mode 100644 index 000000000..f5eb654dc --- /dev/null +++ b/crates/tui/src/commands/groups/config/logout/logout_command.rs @@ -0,0 +1,22 @@ +//! Logout command. + +use super::logout_impl::logout; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Logout; +impl Command for Logout { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "logout", + aliases: &[], + usage: "/logout", + description_id: MessageId::CmdLogoutDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + logout(app) + } +} diff --git a/crates/tui/src/commands/groups/config/logout/logout_impl.rs b/crates/tui/src/commands/groups/config/logout/logout_impl.rs new file mode 100644 index 000000000..84548f31b --- /dev/null +++ b/crates/tui/src/commands/groups/config/logout/logout_impl.rs @@ -0,0 +1,21 @@ +use crate::commands::CommandResult; +use crate::config::clear_active_provider_api_key; +use crate::tui::app::App; +use crate::tui::app::OnboardingState; + +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 ` to clear a different provider." + )) + } + Err(e) => CommandResult::error(format!("Failed to clear API key for {provider_name}: {e}")), + } +} diff --git a/crates/tui/src/commands/groups/config/logout/mod.rs b/crates/tui/src/commands/groups/config/logout/mod.rs new file mode 100644 index 000000000..3b56bb75e --- /dev/null +++ b/crates/tui/src/commands/groups/config/logout/mod.rs @@ -0,0 +1,5 @@ +//! Logout command. + +pub mod logout_command; +pub mod logout_impl; +pub use logout_command::Logout; diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs new file mode 100644 index 000000000..e3d023b2c --- /dev/null +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -0,0 +1,46 @@ +//! Config commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +// The `/config` command intentionally has the same name as the config group. +#[allow(clippy::module_inception)] +pub(crate) mod config; +pub(crate) mod logout; +pub(crate) mod mode; +pub(crate) mod settings; +pub(crate) mod status; +pub(crate) mod statusline; +pub(crate) mod theme; +pub(crate) mod trust; +pub(crate) mod verbose; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::config::Config; +use self::logout::Logout; +use self::mode::Mode; +use self::settings::Settings; +use self::status::Status; +use self::statusline::Statusline; +use self::theme::Theme; +use self::trust::Trust; +use self::verbose::Verbose; + +pub struct ConfigCommands; +impl CommandGroup for ConfigCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(Config), + Box::new(Settings), + Box::new(Status), + Box::new(Statusline), + Box::new(Mode), + Box::new(Theme), + Box::new(Verbose), + Box::new(Trust), + Box::new(Logout), + ] + } +} diff --git a/crates/tui/src/commands/groups/config/mode/mod.rs b/crates/tui/src/commands/groups/config/mode/mod.rs new file mode 100644 index 000000000..eb74c8a45 --- /dev/null +++ b/crates/tui/src/commands/groups/config/mode/mod.rs @@ -0,0 +1,5 @@ +//! Mode command. + +pub mod mode_command; +pub mod mode_impl; +pub use mode_command::Mode; diff --git a/crates/tui/src/commands/groups/config/mode/mode_command.rs b/crates/tui/src/commands/groups/config/mode/mode_command.rs new file mode 100644 index 000000000..8762da01d --- /dev/null +++ b/crates/tui/src/commands/groups/config/mode/mode_command.rs @@ -0,0 +1,21 @@ +//! Mode command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Mode; +impl Command for Mode { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "mode", + aliases: &[], + usage: "/mode [plan|yolo|agent]", + description_id: MessageId::CmdModeDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::config::mode::mode_impl::mode(app, args) + } +} diff --git a/crates/tui/src/commands/groups/config/mode/mode_impl.rs b/crates/tui/src/commands/groups/config/mode/mode_impl.rs new file mode 100644 index 000000000..c2d6d3dee --- /dev/null +++ b/crates/tui/src/commands/groups/config/mode/mode_impl.rs @@ -0,0 +1,54 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction, AppMode}; + +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(crate) 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 { + 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", + } +} diff --git a/crates/tui/src/commands/groups/config/settings/mod.rs b/crates/tui/src/commands/groups/config/settings/mod.rs new file mode 100644 index 000000000..45b4fc975 --- /dev/null +++ b/crates/tui/src/commands/groups/config/settings/mod.rs @@ -0,0 +1,5 @@ +//! Settings command. + +pub mod settings_command; +pub mod settings_impl; +pub use settings_command::Settings; diff --git a/crates/tui/src/commands/groups/config/settings/settings_command.rs b/crates/tui/src/commands/groups/config/settings/settings_command.rs new file mode 100644 index 000000000..9c603d33c --- /dev/null +++ b/crates/tui/src/commands/groups/config/settings/settings_command.rs @@ -0,0 +1,22 @@ +//! Settings command. + +use super::settings_impl::show_settings; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Settings; +impl Command for Settings { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "settings", + aliases: &[], + usage: "/settings", + description_id: MessageId::CmdSettingsDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + show_settings(app) + } +} diff --git a/crates/tui/src/commands/groups/config/settings/settings_impl.rs b/crates/tui/src/commands/groups/config/settings/settings_impl.rs new file mode 100644 index 000000000..5223b2ea0 --- /dev/null +++ b/crates/tui/src/commands/groups/config/settings/settings_impl.rs @@ -0,0 +1,10 @@ +use crate::commands::CommandResult; +use crate::settings::Settings; +use crate::tui::app::App; + +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}")), + } +} diff --git a/crates/tui/src/commands/groups/config/status/mod.rs b/crates/tui/src/commands/groups/config/status/mod.rs new file mode 100644 index 000000000..ef848f4df --- /dev/null +++ b/crates/tui/src/commands/groups/config/status/mod.rs @@ -0,0 +1,8 @@ +//! Status command. +//! +//! This module separates the command handler from the implementation. + +pub mod status_command; +pub mod status_impl; +pub use status_command::Status; +pub use status_impl::status; diff --git a/crates/tui/src/commands/groups/config/status/status_command.rs b/crates/tui/src/commands/groups/config/status/status_command.rs new file mode 100644 index 000000000..a5701682a --- /dev/null +++ b/crates/tui/src/commands/groups/config/status/status_command.rs @@ -0,0 +1,21 @@ +//! Status command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Status; +impl Command for Status { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "status", + aliases: &[], + usage: "/status", + description_id: MessageId::CmdStatusDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + crate::commands::groups::config::status::status(app) + } +} diff --git a/crates/tui/src/commands/status.rs b/crates/tui/src/commands/groups/config/status/status_impl.rs similarity index 99% rename from crates/tui/src/commands/status.rs rename to crates/tui/src/commands/groups/config/status/status_impl.rs index fb1a7e6da..9f663e3bf 100644 --- a/crates/tui/src/commands/status.rs +++ b/crates/tui/src/commands/groups/config/status/status_impl.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; use std::path::Path; -use super::CommandResult; +use crate::commands::CommandResult; use crate::compaction::estimate_input_tokens_conservative; use crate::models::{LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS, context_window_for_model}; use crate::tui::app::App; diff --git a/crates/tui/src/commands/groups/config/statusline/mod.rs b/crates/tui/src/commands/groups/config/statusline/mod.rs new file mode 100644 index 000000000..b6b0c5900 --- /dev/null +++ b/crates/tui/src/commands/groups/config/statusline/mod.rs @@ -0,0 +1,5 @@ +//! Statusline command. + +pub mod statusline_command; +pub mod statusline_impl; +pub use statusline_command::Statusline; diff --git a/crates/tui/src/commands/groups/config/statusline/statusline_command.rs b/crates/tui/src/commands/groups/config/statusline/statusline_command.rs new file mode 100644 index 000000000..e3dddcd35 --- /dev/null +++ b/crates/tui/src/commands/groups/config/statusline/statusline_command.rs @@ -0,0 +1,22 @@ +//! Statusline command. + +use super::statusline_impl::status_line; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Statusline; +impl Command for Statusline { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "statusline", + aliases: &[], + usage: "/statusline", + description_id: MessageId::CmdStatuslineDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + status_line(app) + } +} diff --git a/crates/tui/src/commands/groups/config/statusline/statusline_impl.rs b/crates/tui/src/commands/groups/config/statusline/statusline_impl.rs new file mode 100644 index 000000000..4a17f069f --- /dev/null +++ b/crates/tui/src/commands/groups/config/statusline/statusline_impl.rs @@ -0,0 +1,7 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; +use crate::tui::app::AppAction; + +pub fn status_line(_app: &mut App) -> CommandResult { + CommandResult::action(AppAction::OpenStatusPicker) +} diff --git a/crates/tui/src/commands/groups/config/theme/mod.rs b/crates/tui/src/commands/groups/config/theme/mod.rs new file mode 100644 index 000000000..bdf0f4c97 --- /dev/null +++ b/crates/tui/src/commands/groups/config/theme/mod.rs @@ -0,0 +1,5 @@ +//! Theme command. + +pub mod theme_command; +pub mod theme_impl; +pub use theme_command::Theme; diff --git a/crates/tui/src/commands/groups/config/theme/theme_command.rs b/crates/tui/src/commands/groups/config/theme/theme_command.rs new file mode 100644 index 000000000..4d5999a47 --- /dev/null +++ b/crates/tui/src/commands/groups/config/theme/theme_command.rs @@ -0,0 +1,22 @@ +//! Theme command. + +use super::theme_impl::theme; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Theme; +impl Command for Theme { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "theme", + aliases: &[], + usage: "/theme [name]", + description_id: MessageId::CmdThemeDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + theme(app, args) + } +} diff --git a/crates/tui/src/commands/groups/config/theme/theme_impl.rs b/crates/tui/src/commands/groups/config/theme/theme_impl.rs new file mode 100644 index 000000000..0e69e0ffc --- /dev/null +++ b/crates/tui/src/commands/groups/config/theme/theme_impl.rs @@ -0,0 +1,11 @@ +use crate::commands::CommandResult; +use crate::config_actions::set_config_value; +use crate::tui::app::App; +use crate::tui::app::AppAction; + +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), + } +} diff --git a/crates/tui/src/commands/groups/config/trust/mod.rs b/crates/tui/src/commands/groups/config/trust/mod.rs new file mode 100644 index 000000000..675cc4639 --- /dev/null +++ b/crates/tui/src/commands/groups/config/trust/mod.rs @@ -0,0 +1,5 @@ +//! Trust command. + +pub mod trust_command; +pub mod trust_impl; +pub use trust_command::Trust; diff --git a/crates/tui/src/commands/groups/config/trust/trust_command.rs b/crates/tui/src/commands/groups/config/trust/trust_command.rs new file mode 100644 index 000000000..64feb4609 --- /dev/null +++ b/crates/tui/src/commands/groups/config/trust/trust_command.rs @@ -0,0 +1,22 @@ +//! Trust command. + +use super::trust_impl::trust; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Trust; +impl Command for Trust { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "trust", + aliases: &["xinren"], + usage: "/trust [path]", + description_id: MessageId::CmdTrustDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + trust(app, args) + } +} diff --git a/crates/tui/src/commands/groups/config/trust/trust_impl.rs b/crates/tui/src/commands/groups/config/trust/trust_impl.rs new file mode 100644 index 000000000..211ca0846 --- /dev/null +++ b/crates/tui/src/commands/groups/config/trust/trust_impl.rs @@ -0,0 +1,108 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; +use std::path::Path; +use std::path::PathBuf; + +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 ` 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 `, or `/trust remove `." + )), + } +} + +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 ` 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 . 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 "); + } + 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() +} diff --git a/crates/tui/src/commands/groups/config/verbose/mod.rs b/crates/tui/src/commands/groups/config/verbose/mod.rs new file mode 100644 index 000000000..e53d19611 --- /dev/null +++ b/crates/tui/src/commands/groups/config/verbose/mod.rs @@ -0,0 +1,5 @@ +//! Verbose command. + +pub mod verbose_command; +pub mod verbose_impl; +pub use verbose_command::Verbose; diff --git a/crates/tui/src/commands/groups/config/verbose/verbose_command.rs b/crates/tui/src/commands/groups/config/verbose/verbose_command.rs new file mode 100644 index 000000000..785bf24aa --- /dev/null +++ b/crates/tui/src/commands/groups/config/verbose/verbose_command.rs @@ -0,0 +1,22 @@ +//! Verbose command. + +use super::verbose_impl::verbose; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Verbose; +impl Command for Verbose { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "verbose", + aliases: &[], + usage: "/verbose [on|off]", + description_id: MessageId::CmdVerboseDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + verbose(app, args) + } +} diff --git a/crates/tui/src/commands/groups/config/verbose/verbose_impl.rs b/crates/tui/src/commands/groups/config/verbose/verbose_impl.rs new file mode 100644 index 000000000..254b1b5e9 --- /dev/null +++ b/crates/tui/src/commands/groups/config/verbose/verbose_impl.rs @@ -0,0 +1,26 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +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." + }) +} diff --git a/crates/tui/src/commands/groups/core/agent/agent_command.rs b/crates/tui/src/commands/groups/core/agent/agent_command.rs new file mode 100644 index 000000000..a1e24f2fd --- /dev/null +++ b/crates/tui/src/commands/groups/core/agent/agent_command.rs @@ -0,0 +1,55 @@ +//! Agent command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Agent; +impl Command for Agent { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "agent", + aliases: &["daili"], + usage: "/agent [N] ", + description_id: MessageId::CmdAgentDescription, + } + } + + fn execute(&self, app: &mut App, arg: Option<&str>) -> CommandResult { + super::agent_impl::agent(app, arg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Agent.info(); + assert_eq!(info.name, "agent"); + assert_eq!(info.usage, "/agent [N] "); + assert!(info.aliases.contains(&"daili")); + } + + #[test] + fn execute_requires_task() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Agent.execute(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /agent")); + } + + #[test] + fn execute_sends_agent_open_instruction() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Agent.execute(&mut app, Some("2 inspect the build")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("depth 2")); + let action = result.action.expect("expected send action"); + assert!( + matches!(action, crate::tui::app::AppAction::SendMessage(message) if message.contains("agent_open") && message.contains("inspect the build")) + ); + } +} diff --git a/crates/tui/src/commands/groups/core/agent/agent_impl.rs b/crates/tui/src/commands/groups/core/agent/agent_impl.rs new file mode 100644 index 000000000..dc6afd016 --- /dev/null +++ b/crates/tui/src/commands/groups/core/agent/agent_impl.rs @@ -0,0 +1,47 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let task = match task { + Some(task) if !task.trim().is_empty() => task.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /agent [N] \n\n\ + Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", + ); + } + }; + let message = format!( + "Open a persistent sub-agent session for this task. Call `agent_open` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `agent_eval` to wait for the next terminal/current projection and `handle_read` on the returned transcript_handle if you need more detail. Verify any claimed side effects before reporting success." + ); + CommandResult::with_message_and_action( + format!("Opening persistent sub-agent at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} + +fn parse_depth_prefixed_arg( + arg: Option<&str>, + default_depth: u32, +) -> Result<(u32, Option<&str>), String> { + let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok((default_depth, None)); + }; + let mut parts = raw.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default(); + if first.chars().all(|ch| ch.is_ascii_digit()) { + let depth: u32 = first + .parse() + .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; + if depth > 3 { + return Err("Depth must be between 0 and 3".to_string()); + } + Ok((depth, parts.next().map(str::trim))) + } else { + Ok((default_depth, Some(raw))) + } +} diff --git a/crates/tui/src/commands/groups/core/agent/mod.rs b/crates/tui/src/commands/groups/core/agent/mod.rs new file mode 100644 index 000000000..18194fd1e --- /dev/null +++ b/crates/tui/src/commands/groups/core/agent/mod.rs @@ -0,0 +1,5 @@ +//! Agent command. + +pub mod agent_command; +pub mod agent_impl; +pub use agent_command::Agent; diff --git a/crates/tui/src/commands/groups/core/clear/clear_command.rs b/crates/tui/src/commands/groups/core/clear/clear_command.rs new file mode 100644 index 000000000..7fd97ef81 --- /dev/null +++ b/crates/tui/src/commands/groups/core/clear/clear_command.rs @@ -0,0 +1,73 @@ +//! Clear command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Clear; +impl Command for Clear { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "clear", + aliases: &["qingping"], + usage: "/clear", + description_id: MessageId::CmdClearDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::clear_impl::clear(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Clear; + let info = cmd.info(); + assert_eq!(info.name, "clear"); + assert!(info.aliases.contains(&"qingping")); + assert_eq!(info.usage, "/clear"); + } + + #[test] + fn execute_succeeds() { + let mut app = test_app(); + let result = Clear.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + } +} diff --git a/crates/tui/src/commands/groups/core/clear/clear_impl.rs b/crates/tui/src/commands/groups/core/clear/clear_impl.rs new file mode 100644 index 000000000..95b4fa118 --- /dev/null +++ b/crates/tui/src/commands/groups/core/clear/clear_impl.rs @@ -0,0 +1,130 @@ +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn clear(app: &mut App) -> CommandResult { + let todos_cleared = crate::conversation_state::reset_conversation_state(app); + app.current_session_id = None; + let locale = app.ui_locale; + let message = if todos_cleared { + tr(locale, MessageId::ClearConversation).to_string() + } else { + tr(locale, MessageId::ClearConversationBusy).to_string() + }; + CommandResult::with_message_and_action( + message, + AppAction::SyncSession { + session_id: None, + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::PromptInspection; + use crate::commands::groups::core::test_support::create_test_app; + use crate::models::Message; + use crate::tui::app::TurnCacheRecord; + use crate::tui::history::HistoryCell; + use std::path::PathBuf; + use std::time::Instant; + + #[test] + fn test_clear_resets_all_state() { + let mut app = create_test_app(); + app.history.push(HistoryCell::User { + content: "test".to_string(), + }); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![], + }); + app.session.total_conversation_tokens = 100; + app.tool_log.push("test".to_string()); + app.current_session_id = Some("existing-session".to_string()); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "existing-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "tool output".to_string(), + storage_path: PathBuf::from("/tmp/tool_outputs/call-big.txt"), + }); + + let result = clear(&mut app); + + assert!(result.message.is_some()); + assert!(app.history.is_empty()); + assert!(app.api_messages.is_empty()); + assert_eq!(app.session.total_conversation_tokens, 0); + assert!(app.tool_log.is_empty()); + assert!(app.tool_cells.is_empty()); + assert!(app.tool_details_by_cell.is_empty()); + assert!(app.session_artifacts.is_empty()); + assert!(app.current_session_id.is_none()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + } + + #[test] + fn clear_resets_session_telemetry() { + let mut app = create_test_app(); + app.session.total_tokens = 234; + app.session.total_conversation_tokens = 123; + app.session.session_cost = 0.42; + app.session.session_cost_cny = 3.05; + app.session.subagent_cost = 0.11; + app.session.subagent_cost_cny = 0.80; + app.session.subagent_cost_event_seqs.insert(7); + app.session.displayed_cost_high_water = 0.53; + app.session.displayed_cost_high_water_cny = 3.85; + app.session.last_prompt_cache_hit_tokens = Some(70); + app.session.last_prompt_cache_miss_tokens = Some(30); + app.session.last_reasoning_replay_tokens = Some(12); + app.session.last_warmup_key = None; + app.session.last_tool_catalog = Some(Vec::new()); + app.session.last_base_url = Some("https://api.deepseek.com".to_string()); + app.session.last_cache_inspection = Some(PromptInspection { + base_static_prefix_hash: "base".to_string(), + full_request_prefix_hash: "full".to_string(), + tool_catalog_hash: String::new(), + layers: Vec::new(), + }); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 100, + output_tokens: 25, + cache_hit_tokens: Some(70), + cache_miss_tokens: Some(30), + reasoning_replay_tokens: Some(12), + recorded_at: Instant::now(), + }); + + clear(&mut app); + + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.total_conversation_tokens, 0); + assert_eq!(app.session.session_cost, 0.0); + assert_eq!(app.session.session_cost_cny, 0.0); + assert_eq!(app.session.subagent_cost, 0.0); + assert_eq!(app.session.subagent_cost_cny, 0.0); + assert!(app.session.subagent_cost_event_seqs.is_empty()); + assert_eq!(app.session.displayed_cost_high_water, 0.0); + assert_eq!(app.session.displayed_cost_high_water_cny, 0.0); + assert_eq!(app.session.last_prompt_cache_hit_tokens, None); + assert_eq!(app.session.last_prompt_cache_miss_tokens, None); + assert_eq!(app.session.last_reasoning_replay_tokens, None); + assert!(app.session.turn_cache_history.is_empty()); + assert_eq!(app.session.last_cache_inspection, None); + assert_eq!(app.session.last_warmup_key, None); + assert_eq!(app.session.last_tool_catalog, None); + assert_eq!(app.session.last_base_url, None); + } +} diff --git a/crates/tui/src/commands/groups/core/clear/mod.rs b/crates/tui/src/commands/groups/core/clear/mod.rs new file mode 100644 index 000000000..9aa21c72e --- /dev/null +++ b/crates/tui/src/commands/groups/core/clear/mod.rs @@ -0,0 +1,5 @@ +//! Clear command. + +pub mod clear_command; +pub mod clear_impl; +pub use clear_command::Clear; diff --git a/crates/tui/src/commands/groups/core/exit/exit_command.rs b/crates/tui/src/commands/groups/core/exit/exit_command.rs new file mode 100644 index 000000000..b688e71cb --- /dev/null +++ b/crates/tui/src/commands/groups/core/exit/exit_command.rs @@ -0,0 +1,78 @@ +//! Exit command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Exit; +impl Command for Exit { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "exit", + aliases: &["quit", "q", "tuichu"], + usage: "/exit", + description_id: MessageId::CmdExitDescription, + } + } + fn execute(&self, _app: &mut App, _args: Option<&str>) -> CommandResult { + super::exit_impl::exit() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Exit; + let info = cmd.info(); + assert_eq!(info.name, "exit"); + assert!(info.aliases.contains(&"quit")); + assert!(info.aliases.contains(&"q")); + assert!(info.aliases.contains(&"tuichu")); + } + + #[test] + fn execute_returns_quit_action() { + let mut app = test_app(); + let result = Exit.execute(&mut app, None); + assert!(!result.is_error); + assert!( + matches!(result.action, Some(crate::tui::app::AppAction::Quit)), + "expected Quit, got {:?}", + result.action + ); + } +} diff --git a/crates/tui/src/commands/groups/core/exit/exit_impl.rs b/crates/tui/src/commands/groups/core/exit/exit_impl.rs new file mode 100644 index 000000000..61dcb1c99 --- /dev/null +++ b/crates/tui/src/commands/groups/core/exit/exit_impl.rs @@ -0,0 +1,18 @@ +use crate::commands::CommandResult; +use crate::tui::app::AppAction; + +pub(crate) fn exit() -> CommandResult { + CommandResult::action(AppAction::Quit) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exit_returns_quit_action() { + let result = exit(); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::Quit))); + } +} diff --git a/crates/tui/src/commands/groups/core/exit/mod.rs b/crates/tui/src/commands/groups/core/exit/mod.rs new file mode 100644 index 000000000..103085c1a --- /dev/null +++ b/crates/tui/src/commands/groups/core/exit/mod.rs @@ -0,0 +1,5 @@ +//! Exit command. + +pub mod exit_command; +pub mod exit_impl; +pub use exit_command::Exit; diff --git a/crates/tui/src/commands/groups/core/feedback/feedback_command.rs b/crates/tui/src/commands/groups/core/feedback/feedback_command.rs new file mode 100644 index 000000000..d5555889f --- /dev/null +++ b/crates/tui/src/commands/groups/core/feedback/feedback_command.rs @@ -0,0 +1,93 @@ +//! Feedback command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Feedback; +impl Command for Feedback { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "feedback", + aliases: &[], + usage: "/feedback [bug|feature|security]", + description_id: MessageId::CmdFeedbackDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::core::feedback::feedback(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Feedback; + let info = cmd.info(); + assert_eq!(info.name, "feedback"); + assert!(info.aliases.is_empty()); + } + + #[test] + fn execute_without_args_shows_usage() { + let mut app = test_app(); + let result = Feedback.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_bug_type_succeeds() { + let mut app = test_app(); + let result = Feedback.execute(&mut app, Some("bug")); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_feature_type_succeeds() { + let mut app = test_app(); + let result = Feedback.execute(&mut app, Some("feature")); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_security_type_succeeds() { + let mut app = test_app(); + let result = Feedback.execute(&mut app, Some("security")); + assert!(!result.is_error, "{:?}", result.message); + } +} diff --git a/crates/tui/src/commands/feedback.rs b/crates/tui/src/commands/groups/core/feedback/feedback_impl.rs similarity index 99% rename from crates/tui/src/commands/feedback.rs rename to crates/tui/src/commands/groups/core/feedback/feedback_impl.rs index fc968c73a..74f71ee8f 100644 --- a/crates/tui/src/commands/feedback.rs +++ b/crates/tui/src/commands/groups/core/feedback/feedback_impl.rs @@ -1,4 +1,4 @@ -use super::CommandResult; +use crate::commands::CommandResult; use crate::tui::app::{App, AppAction}; const SECURITY_POLICY_URL: &str = "https://github.com/Hmbown/CodeWhale/security/policy"; diff --git a/crates/tui/src/commands/groups/core/feedback/mod.rs b/crates/tui/src/commands/groups/core/feedback/mod.rs new file mode 100644 index 000000000..d63be1931 --- /dev/null +++ b/crates/tui/src/commands/groups/core/feedback/mod.rs @@ -0,0 +1,8 @@ +//! Feedback command. +//! +//! This module separates the command handler from the implementation. + +pub mod feedback_command; +pub mod feedback_impl; +pub use feedback_command::Feedback; +pub use feedback_impl::feedback; diff --git a/crates/tui/src/commands/groups/core/help/help_command.rs b/crates/tui/src/commands/groups/core/help/help_command.rs new file mode 100644 index 000000000..935de04bc --- /dev/null +++ b/crates/tui/src/commands/groups/core/help/help_command.rs @@ -0,0 +1,79 @@ +//! Help command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Help; +impl Command for Help { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "help", + aliases: &["?", "bangzhu", "\u{5e2e}\u{52a9}"], + usage: "/help [command]", + description_id: MessageId::CmdHelpDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::help_impl::help(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let info = Help.info(); + assert_eq!(info.name, "help"); + assert!(info.aliases.contains(&"?")); + assert!(info.aliases.contains(&"bangzhu")); + assert_eq!(info.usage, "/help [command]"); + } + + #[test] + fn execute_topic_help_succeeds() { + let mut app = test_app(); + let result = Help.execute(&mut app, Some("model")); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_nonexistent_topic_returns_error() { + let mut app = test_app(); + let result = Help.execute(&mut app, Some("nonexistent")); + assert!(result.is_error); + } +} diff --git a/crates/tui/src/commands/groups/core/help/help_impl.rs b/crates/tui/src/commands/groups/core/help/help_impl.rs new file mode 100644 index 000000000..a27156b3e --- /dev/null +++ b/crates/tui/src/commands/groups/core/help/help_impl.rs @@ -0,0 +1,113 @@ +use std::fmt::Write; + +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::App; +use crate::tui::views::{HelpView, ModalKind}; + +pub(crate) fn help(app: &mut App, topic: Option<&str>) -> CommandResult { + if let Some(topic) = topic { + if let Some(cmd) = crate::commands::registry().get_info(topic) { + let mut help = format!( + "{}\n\n {}\n\n {} {}", + cmd.name, + cmd.description_for(app.ui_locale), + tr(app.ui_locale, MessageId::HelpUsageLabel), + cmd.usage + ); + if !cmd.aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + tr(app.ui_locale, MessageId::HelpAliasesLabel), + cmd.aliases.join(", ") + ); + } + return CommandResult::message(help); + } + return CommandResult::error( + tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), + ); + } + + if app.view_stack.top_kind() != Some(ModalKind::Help) { + app.view_stack.push(HelpView::new_for_locale(app.ui_locale)); + } + CommandResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn test_help_unknown_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("nonexistent")); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Unknown command")); + assert!(result.action.is_none()); + } + + #[test] + fn test_help_known_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("clear")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("clear")); + assert!(msg.contains("Clear conversation history")); + assert!(msg.contains("Usage: /clear")); + } + + #[test] + fn test_help_config_topic_uses_interactive_editor_text() { + let mut app = create_test_app(); + let result = help(&mut app, Some("config")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("config")); + assert!(msg.contains("Open interactive configuration editor")); + assert!(msg.contains("Usage: /config")); + } + + #[test] + fn test_help_links_topic_shows_aliases() { + let mut app = create_test_app(); + let result = help(&mut app, Some("links")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("links")); + assert!(msg.contains("Show DeepSeek dashboard and docs links")); + assert!(msg.contains("Usage: /links")); + assert!(msg.contains("Aliases: dashboard, api")); + } + + #[test] + fn test_help_memory_topic_shows_usage_and_description() { + let mut app = create_test_app(); + let result = help(&mut app, Some("memory")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("memory")); + assert!(msg.contains("persistent user-memory file")); + assert!(msg.contains("Usage: /memory [show|path|clear|edit|help]")); + } + + #[test] + fn test_help_pushes_overlay() { + let mut app = create_test_app(); + assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); + let result = help(&mut app, None); + assert_eq!(result.message, None); + assert_eq!(result.action, None); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); + } + + #[test] + fn test_help_does_not_duplicate_overlay() { + let mut app = create_test_app(); + help(&mut app, None); + let initial_kind = app.view_stack.top_kind(); + help(&mut app, None); + assert_eq!(app.view_stack.top_kind(), initial_kind); + } +} diff --git a/crates/tui/src/commands/groups/core/help/mod.rs b/crates/tui/src/commands/groups/core/help/mod.rs new file mode 100644 index 000000000..c4aabfbc3 --- /dev/null +++ b/crates/tui/src/commands/groups/core/help/mod.rs @@ -0,0 +1,5 @@ +//! Help command. + +pub mod help_command; +pub mod help_impl; +pub use help_command::Help; diff --git a/crates/tui/src/commands/groups/core/home/home_command.rs b/crates/tui/src/commands/groups/core/home/home_command.rs new file mode 100644 index 000000000..7998ba751 --- /dev/null +++ b/crates/tui/src/commands/groups/core/home/home_command.rs @@ -0,0 +1,75 @@ +//! Home command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Home; +impl Command for Home { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "home", + aliases: &["stats", "overview", "zhuye", "shouye"], + usage: "/home", + description_id: MessageId::CmdHomeDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::home_impl::home_dashboard(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Home; + let info = cmd.info(); + assert_eq!(info.name, "home"); + assert!(info.aliases.contains(&"stats")); + assert!(info.aliases.contains(&"overview")); + } + + #[test] + fn execute_returns_dashboard_message() { + let mut app = test_app(); + let result = Home.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + let msg = result.message.as_deref().unwrap_or(""); + assert!(!msg.is_empty(), "home should have a message"); + } +} diff --git a/crates/tui/src/commands/groups/core/home/home_impl.rs b/crates/tui/src/commands/groups/core/home/home_impl.rs new file mode 100644 index 000000000..dee81fbe4 --- /dev/null +++ b/crates/tui/src/commands/groups/core/home/home_impl.rs @@ -0,0 +1,197 @@ +use std::fmt::Write; + +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::{App, AppMode}; + +pub(crate) fn home_dashboard(app: &mut App) -> CommandResult { + let locale = app.ui_locale; + let mut stats = String::new(); + + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle)); + let _ = writeln!(stats, "============================================"); + let _ = writeln!( + stats, + "{} {}", + tr(locale, MessageId::HomeModel), + app.model + ); + let _ = writeln!( + stats, + "{} {}", + tr(locale, MessageId::HomeMode), + app.mode.label() + ); + let _ = writeln!( + stats, + "{} {}", + tr(locale, MessageId::HomeWorkspace), + app.workspace.display() + ); + + let history_count = app.history.len(); + let total_tokens = app.session.total_conversation_tokens; + let queued_messages = app.queued_messages.len(); + let _ = writeln!( + stats, + "{} {} messages", + tr(locale, MessageId::HomeHistory), + history_count + ); + let _ = writeln!( + stats, + "{} {} (session)", + tr(locale, MessageId::HomeTokens), + total_tokens + ); + if queued_messages > 0 { + let _ = writeln!( + stats, + "{} {} messages", + tr(locale, MessageId::HomeQueued), + queued_messages + ); + } + + let subagent_count = app.subagent_cache.len(); + if subagent_count > 0 { + let _ = writeln!( + stats, + "{} {} active", + tr(locale, MessageId::HomeSubagents), + subagent_count + ); + } + + if let Some(skill) = &app.active_skill { + let _ = writeln!( + stats, + "{} {} (active)", + tr(locale, MessageId::HomeSkill), + skill + ); + } + + let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions)); + let _ = writeln!(stats, "--------------------------------------------"); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp)); + + let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips)); + let _ = writeln!(stats, "--------------------------------------------"); + match app.mode { + AppMode::Agent => { + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip)); + } + AppMode::Yolo => { + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeTip)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeYoloModeCaution)); + } + AppMode::Plan => { + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip)); + let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip)); + } + } + + CommandResult::message(stats) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn test_home_dashboard_includes_all_sections() { + let mut app = create_test_app(); + app.session.total_conversation_tokens = 1234; + let result = home_dashboard(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("codewhale Home Dashboard")); + assert!(msg.contains("Model:")); + assert!(msg.contains("Mode:")); + assert!(msg.contains("Workspace:")); + assert!(msg.contains("History:")); + assert!(msg.contains("Tokens:")); + assert!(msg.contains("Quick Actions")); + assert!(msg.contains("Mode Tips")); + assert!(result.action.is_none()); + } + + #[test] + fn test_home_dashboard_shows_queued_when_present() { + let mut app = create_test_app(); + app.queued_messages + .push_back(crate::tui::app::QueuedMessage::new( + "test".to_string(), + None, + )); + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Queued:")); + } + + #[test] + fn test_home_dashboard_mode_tips_for_each_mode() { + let modes = [AppMode::Agent, AppMode::Yolo, AppMode::Plan]; + for mode in modes { + let mut app = create_test_app(); + app.mode = mode; + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); + } + } + + #[test] + fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { + let mut app = create_test_app(); + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!(msg.contains("/links - Dashboard & API links")); + assert!(msg.contains("/config - Open interactive configuration editor")); + assert!( + !msg.lines() + .any(|line| line.trim_start().starts_with("/set ")) + ); + assert!(!msg.contains("/codewhale")); + } + + #[test] + fn home_dashboard_localizes_in_zh_hans() { + use crate::localization::Locale; + let mut app = create_test_app(); + app.ui_locale = Locale::ZhHans; + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!( + msg.contains("\u{4e3b}\u{9762}\u{677f}"), + "missing zh-Hans title:\n{msg}" + ); + assert!( + msg.contains("\u{6a21}\u{578b}"), + "missing zh-Hans model label:\n{msg}" + ); + assert!( + msg.contains("\u{5feb}\u{6377}\u{64cd}\u{4f5c}"), + "missing zh-Hans quick actions:\n{msg}" + ); + assert!( + msg.contains("\u{6a21}\u{5f0f}\u{63d0}\u{793a}"), + "missing zh-Hans mode tips:\n{msg}" + ); + } +} diff --git a/crates/tui/src/commands/groups/core/home/mod.rs b/crates/tui/src/commands/groups/core/home/mod.rs new file mode 100644 index 000000000..195ea94dd --- /dev/null +++ b/crates/tui/src/commands/groups/core/home/mod.rs @@ -0,0 +1,5 @@ +//! Home command. + +pub mod home_command; +pub mod home_impl; +pub use home_command::Home; diff --git a/crates/tui/src/commands/groups/core/links/links_command.rs b/crates/tui/src/commands/groups/core/links/links_command.rs new file mode 100644 index 000000000..37c2fdb30 --- /dev/null +++ b/crates/tui/src/commands/groups/core/links/links_command.rs @@ -0,0 +1,78 @@ +//! Links command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Links; +impl Command for Links { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "links", + aliases: &["dashboard", "api", "lianjie"], + usage: "/links", + description_id: MessageId::CmdLinksDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::links_impl::deepseek_links(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Links; + let info = cmd.info(); + assert_eq!(info.name, "links"); + assert!(info.aliases.contains(&"dashboard")); + assert!(info.aliases.contains(&"api")); + } + + #[test] + fn execute_returns_links_message() { + let mut app = test_app(); + let result = Links.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + let msg = result.message.as_deref().unwrap_or(""); + assert!( + msg.contains("dashboard") || msg.contains("api"), + "links msg: {msg}" + ); + } +} diff --git a/crates/tui/src/commands/groups/core/links/links_impl.rs b/crates/tui/src/commands/groups/core/links/links_impl.rs new file mode 100644 index 000000000..02e9ab1a1 --- /dev/null +++ b/crates/tui/src/commands/groups/core/links/links_impl.rs @@ -0,0 +1,35 @@ +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::App; + +pub(crate) fn deepseek_links(app: &mut App) -> CommandResult { + let locale = app.ui_locale; + CommandResult::message(format!( + "{}\n\ +-----------------------------\n\ +{} https://platform.deepseek.com\n\ +{} https://platform.deepseek.com/docs\n\n\ +{}", + tr(locale, MessageId::LinksTitle), + tr(locale, MessageId::LinksDashboard), + tr(locale, MessageId::LinksDocs), + tr(locale, MessageId::LinksTip), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn test_deepseek_links() { + let mut app = create_test_app(); + let result = deepseek_links(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("DeepSeek Links")); + assert!(msg.contains("https://platform.deepseek.com")); + assert!(result.action.is_none()); + } +} diff --git a/crates/tui/src/commands/groups/core/links/mod.rs b/crates/tui/src/commands/groups/core/links/mod.rs new file mode 100644 index 000000000..b5bba4419 --- /dev/null +++ b/crates/tui/src/commands/groups/core/links/mod.rs @@ -0,0 +1,5 @@ +//! Links command. + +pub mod links_command; +pub mod links_impl; +pub use links_command::Links; diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs new file mode 100644 index 000000000..36aa26814 --- /dev/null +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -0,0 +1,46 @@ +//! Core commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod agent; +pub(crate) mod clear; +pub(crate) mod exit; +pub(crate) mod feedback; +pub(crate) mod help; +pub(crate) mod home; +pub(crate) mod links; +pub(crate) mod model; +pub(crate) mod models; +pub(crate) mod profile; +pub(crate) mod provider; +pub(crate) mod relay; +pub(crate) mod subagents; +#[cfg(test)] +pub(crate) mod test_support; +pub(crate) mod workspace; + +use crate::commands::traits::{Command, CommandGroup}; + +pub struct CoreCommands; +impl CommandGroup for CoreCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(help::Help), + Box::new(clear::Clear), + Box::new(exit::Exit), + Box::new(model::Model), + Box::new(models::Models), + Box::new(provider::Provider), + Box::new(links::Links), + Box::new(feedback::Feedback), + Box::new(home::Home), + Box::new(workspace::Workspace), + Box::new(subagents::Subagents), + Box::new(agent::Agent), + Box::new(profile::Profile), + Box::new(relay::Relay), + ] + } +} diff --git a/crates/tui/src/commands/groups/core/model/mod.rs b/crates/tui/src/commands/groups/core/model/mod.rs new file mode 100644 index 000000000..b12e463bd --- /dev/null +++ b/crates/tui/src/commands/groups/core/model/mod.rs @@ -0,0 +1,5 @@ +//! Model command. + +pub mod model_command; +pub mod model_impl; +pub use model_command::Model; diff --git a/crates/tui/src/commands/groups/core/model/model_command.rs b/crates/tui/src/commands/groups/core/model/model_command.rs new file mode 100644 index 000000000..7faaaf0d9 --- /dev/null +++ b/crates/tui/src/commands/groups/core/model/model_command.rs @@ -0,0 +1,86 @@ +//! Model command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Model; +impl Command for Model { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "model", + aliases: &["moxing"], + usage: "/model [name]", + description_id: MessageId::CmdModelDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::model_impl::model(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Model; + let info = cmd.info(); + assert_eq!(info.name, "model"); + assert!(info.aliases.contains(&"moxing")); + } + + #[test] + fn execute_without_args_shows_current() { + let mut app = test_app(); + let result = Model.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_model_name_switches() { + let mut app = test_app(); + let result = Model.execute(&mut app, Some("deepseek-v4-flash")); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_full_model_spec_succeeds() { + let mut app = test_app(); + let result = Model.execute(&mut app, Some("deepseek-v4-flash")); + assert!(!result.is_error, "{:?}", result.message); + } +} diff --git a/crates/tui/src/commands/groups/core/model/model_impl.rs b/crates/tui/src/commands/groups/core/model/model_impl.rs new file mode 100644 index 000000000..5d83b9101 --- /dev/null +++ b/crates/tui/src/commands/groups/core/model/model_impl.rs @@ -0,0 +1,357 @@ +use crate::commands::CommandResult; +use crate::config::{ + ApiProvider, COMMON_DEEPSEEK_MODELS, normalize_custom_model_id, + normalize_model_name_for_provider, provider_passes_model_through, +}; +use crate::localization::{MessageId, tr}; +use crate::tui::app::{App, AppAction, ReasoningEffort}; + +pub(crate) fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { + if let Some(name) = model_name { + if name.trim().eq_ignore_ascii_case("auto") { + return switch_to_auto_model(app); + } + + let model_id = if app.accepts_custom_model_ids() { + let Some(model_id) = normalize_custom_model_id(name) else { + return CommandResult::error(format!( + "Invalid model '{name}'. Expected a non-empty model ID." + )); + }; + model_id + } else { + let Some(model_id) = normalize_model_name_for_provider(app.api_provider, name) else { + if let Some((provider, model_id)) = saved_provider_model_match(app, name) { + return CommandResult::with_message_and_action( + format!( + "Switching provider to {} for model {model_id}.", + provider.as_str() + ), + AppAction::SwitchProvider { + provider, + model: Some(model_id), + }, + ); + } + return CommandResult::error(format!( + "Invalid model '{name}'. Expected auto, a model for the active provider, or a saved provider model. Common DeepSeek models: {}", + COMMON_DEEPSEEK_MODELS.join(", ") + )); + }; + model_id + }; + + switch_to_model(app, model_id) + } else { + CommandResult::action(AppAction::OpenModelPicker) + } +} + +fn switch_to_auto_model(app: &mut App) -> CommandResult { + let old_model = app.model_display_label(); + let model_changed = !app.auto_model || app.model != "auto"; + app.auto_model = true; + app.model = "auto".to_string(); + app.last_effective_model = None; + app.reasoning_effort = ReasoningEffort::Auto; + app.last_effective_reasoning_effort = None; + app.update_model_compaction_budget(); + if model_changed { + app.clear_model_scoped_telemetry(); + } else { + app.session.last_prompt_tokens = None; + app.session.last_completion_tokens = None; + } + app.provider_models + .insert(app.api_provider.as_str().to_string(), "auto".to_string()); + let persist_warning = provider_model_selection_persist_warning(app.api_provider, "auto"); + let mut message = tr(app.ui_locale, MessageId::ModelChanged) + .replace("{old}", &old_model) + .replace("{new}", "auto"); + if let Some(warning) = persist_warning { + message.push_str(&warning); + } + CommandResult::with_message_and_action( + message, + AppAction::UpdateCompaction(app.compaction_config()), + ) +} + +fn switch_to_model(app: &mut App, model_id: String) -> CommandResult { + let old_model = app.model_display_label(); + let model_changed = app.auto_model || app.model != model_id; + app.auto_model = false; + app.model = model_id.clone(); + app.last_effective_model = None; + app.update_model_compaction_budget(); + if model_changed { + app.clear_model_scoped_telemetry(); + } else { + app.session.last_prompt_tokens = None; + app.session.last_completion_tokens = None; + } + app.provider_models + .insert(app.api_provider.as_str().to_string(), model_id.clone()); + let persist_warning = provider_model_selection_persist_warning(app.api_provider, &model_id); + let mut message = tr(app.ui_locale, MessageId::ModelChanged) + .replace("{old}", &old_model) + .replace("{new}", &model_id); + if let Some(warning) = persist_warning { + message.push_str(&warning); + } + CommandResult::with_message_and_action( + message, + AppAction::UpdateCompaction(app.compaction_config()), + ) +} + +fn provider_model_selection_persist_warning(provider: ApiProvider, model: &str) -> Option { + crate::settings::Settings::persist_provider_model_selection(provider, model) + .err() + .map(|err| format!(" (not persisted: {err})")) +} + +fn saved_provider_model_match(app: &App, name: &str) -> Option<(ApiProvider, String)> { + let requested = normalize_custom_model_id(name)?; + let mut saved = app + .provider_models + .iter() + .filter_map(|(provider_name, model)| { + let provider = ApiProvider::parse(provider_name)?; + (provider != app.api_provider).then_some((provider, model.as_str())) + }) + .collect::>(); + saved.sort_by_key(|(provider, _)| provider.as_str()); + + for (provider, saved_model) in saved { + let Some(saved_model) = normalize_model_for_provider_selection(provider, saved_model) + else { + continue; + }; + let requested_model = normalize_model_for_provider_selection(provider, &requested) + .unwrap_or_else(|| requested.clone()); + if saved_model.eq_ignore_ascii_case(&requested_model) + || saved_model.eq_ignore_ascii_case(&requested) + { + return Some((provider, saved_model)); + } + } + + None +} + +fn normalize_model_for_provider_selection(provider: ApiProvider, model: &str) -> Option { + if provider_passes_model_through(provider) { + normalize_custom_model_id(model) + } else { + normalize_model_name_for_provider(provider, model) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::{SettingsPathGuard, create_test_app}; + use crate::tui::app::TurnCacheRecord; + use std::time::Instant; + + #[test] + fn test_model_change_updates_state() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + let old_model = app.model.clone(); + + let result = model(&mut app, Some("deepseek-v4-flash")); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains(&old_model)); + assert!(msg.contains("deepseek-v4-flash")); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + assert_eq!(app.model, "deepseek-v4-flash"); + assert_eq!(app.session.last_prompt_tokens, None); + assert_eq!(app.session.last_completion_tokens, None); + } + + #[test] + fn model_command_persists_active_provider_model() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + + let result = model(&mut app, Some("deepseek-v4-flash")); + + assert!(result.message.is_some()); + assert_eq!( + app.provider_models.get("deepseek").map(String::as_str), + Some("deepseek-v4-flash") + ); + let settings = crate::settings::Settings::load().expect("load settings"); + assert_eq!(settings.default_provider.as_deref(), Some("deepseek")); + assert_eq!(settings.default_model.as_deref(), Some("deepseek-v4-flash")); + assert_eq!( + settings + .provider_models + .as_ref() + .and_then(|models| models.get("deepseek")) + .map(String::as_str), + Some("deepseek-v4-flash") + ); + } + + #[test] + fn model_switch_clears_turn_cache_history() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + app.auto_model = false; + app.model = "deepseek-v4-pro".to_string(); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 100, + output_tokens: 25, + cache_hit_tokens: Some(70), + cache_miss_tokens: Some(30), + reasoning_replay_tokens: Some(12), + recorded_at: Instant::now(), + }); + + let result = model(&mut app, Some("deepseek-v4-flash")); + + assert!(result.message.is_some()); + assert!(app.session.turn_cache_history.is_empty()); + } + + #[test] + fn model_reset_same_model_keeps_turn_cache_history() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + app.auto_model = false; + app.model = "deepseek-v4-pro".to_string(); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 100, + output_tokens: 25, + cache_hit_tokens: Some(70), + cache_miss_tokens: Some(30), + reasoning_replay_tokens: Some(12), + recorded_at: Instant::now(), + }); + + let result = model(&mut app, Some("deepseek-v4-pro")); + + assert!(result.message.is_some()); + assert_eq!(app.session.turn_cache_history.len(), 1); + } + + #[test] + fn test_model_auto_enables_auto_thinking() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + app.reasoning_effort = ReasoningEffort::Off; + + let result = model(&mut app, Some("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 test_model_change_accepts_future_deepseek_model() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + + let result = model(&mut app, Some("deepseek-v4")); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("deepseek-v4")); + assert_eq!(app.model, "deepseek-v4"); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn test_model_change_accepts_custom_id_for_openai_compatible_provider() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + app.api_provider = crate::config::ApiProvider::Openai; + app.model_ids_passthrough = true; + + let result = model(&mut app, Some("opencode-go/glm-5.1")); + + assert!(result.message.is_some()); + assert_eq!(app.model, "opencode-go/glm-5.1"); + assert!(!app.auto_model); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn test_model_change_accepts_custom_id_for_custom_base_url() { + let _settings = SettingsPathGuard::new(); + let mut app = create_test_app(); + app.model_ids_passthrough = true; + + let result = model(&mut app, Some("opencode-go/kimi-k2.6")); + + assert!(result.message.is_some()); + assert_eq!(app.model, "opencode-go/kimi-k2.6"); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn test_model_change_rejects_invalid_model() { + let mut app = create_test_app(); + + let result = model(&mut app, Some("gpt-4")); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Invalid model")); + assert!(msg.contains("active provider")); + assert!(msg.contains("deepseek-v4-pro")); + assert!(msg.contains("deepseek-v4-flash")); + assert!(result.action.is_none()); + } + + #[test] + fn model_command_switches_to_saved_provider_model() { + let mut app = create_test_app(); + app.api_provider = crate::config::ApiProvider::Deepseek; + app.provider_models + .insert("moonshot".to_string(), "kimi-k2.6".to_string()); + + let result = model(&mut app, Some("kimi-k2.6")); + + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, crate::config::ApiProvider::Moonshot); + assert_eq!(model.as_deref(), Some("kimi-k2.6")); + } + other => panic!("expected SwitchProvider action, got {other:?}"), + } + assert_eq!(app.api_provider, crate::config::ApiProvider::Deepseek); + assert_eq!(app.model, "deepseek-v4-pro"); + } + + #[test] + fn test_model_without_args_opens_picker() { + let mut app = create_test_app(); + + let result = model(&mut app, None); + + assert_eq!(result.message, None); + assert_eq!(result.action, Some(AppAction::OpenModelPicker)); + } +} diff --git a/crates/tui/src/commands/groups/core/models/mod.rs b/crates/tui/src/commands/groups/core/models/mod.rs new file mode 100644 index 000000000..2627c44be --- /dev/null +++ b/crates/tui/src/commands/groups/core/models/mod.rs @@ -0,0 +1,5 @@ +//! Models command. + +pub mod models_command; +pub mod models_impl; +pub use models_command::Models; diff --git a/crates/tui/src/commands/groups/core/models/models_command.rs b/crates/tui/src/commands/groups/core/models/models_command.rs new file mode 100644 index 000000000..56dd79a21 --- /dev/null +++ b/crates/tui/src/commands/groups/core/models/models_command.rs @@ -0,0 +1,77 @@ +//! Models command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Models; +impl Command for Models { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "models", + aliases: &["moxingliebiao"], + usage: "/models", + description_id: MessageId::CmdModelsDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::models_impl::models(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, AppAction, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Models; + let info = cmd.info(); + assert_eq!(info.name, "models"); + assert!(info.aliases.contains(&"moxingliebiao")); + } + + #[test] + fn execute_returns_fetch_action() { + let mut app = test_app(); + let result = Models.execute(&mut app, None); + assert!(!result.is_error); + assert!( + matches!(result.action, Some(AppAction::FetchModels)), + "expected FetchModels, got {:?}", + result.action + ); + } +} diff --git a/crates/tui/src/commands/groups/core/models/models_impl.rs b/crates/tui/src/commands/groups/core/models/models_impl.rs new file mode 100644 index 000000000..be7d0f483 --- /dev/null +++ b/crates/tui/src/commands/groups/core/models/models_impl.rs @@ -0,0 +1,20 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn models(_app: &mut App) -> CommandResult { + CommandResult::action(AppAction::FetchModels) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn test_models_triggers_fetch_action() { + let mut app = create_test_app(); + let result = models(&mut app); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::FetchModels))); + } +} diff --git a/crates/tui/src/commands/groups/core/profile/mod.rs b/crates/tui/src/commands/groups/core/profile/mod.rs new file mode 100644 index 000000000..fe2d3ed8a --- /dev/null +++ b/crates/tui/src/commands/groups/core/profile/mod.rs @@ -0,0 +1,5 @@ +//! Profile command. + +pub mod profile_command; +pub mod profile_impl; +pub use profile_command::Profile; diff --git a/crates/tui/src/commands/groups/core/profile/profile_command.rs b/crates/tui/src/commands/groups/core/profile/profile_command.rs new file mode 100644 index 000000000..8749e6c97 --- /dev/null +++ b/crates/tui/src/commands/groups/core/profile/profile_command.rs @@ -0,0 +1,79 @@ +//! Profile command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Profile; +impl Command for Profile { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "profile", + aliases: &["dangan"], + usage: "/profile ", + description_id: MessageId::CmdHelpDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::profile_impl::profile_switch(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Profile; + let info = cmd.info(); + assert_eq!(info.name, "profile"); + assert!(info.aliases.contains(&"dangan")); + } + + #[test] + fn execute_without_args_returns_error() { + let mut app = test_app(); + let result = Profile.execute(&mut app, None); + assert!(result.is_error, "profile requires an argument"); + } + + #[test] + fn execute_with_name_succeeds() { + let mut app = test_app(); + let result = Profile.execute(&mut app, Some("default")); + assert!(!result.is_error, "{:?}", result.message); + } +} diff --git a/crates/tui/src/commands/groups/core/profile/profile_impl.rs b/crates/tui/src/commands/groups/core/profile/profile_impl.rs new file mode 100644 index 000000000..91948085d --- /dev/null +++ b/crates/tui/src/commands/groups/core/profile/profile_impl.rs @@ -0,0 +1,43 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn profile_switch(_app: &mut App, arg: Option<&str>) -> CommandResult { + let profile_name = match arg { + Some(name) if !name.trim().is_empty() => name.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /profile \n\nSwitch to a named config profile. Profiles are defined in ~/.codewhale/config.toml under [profiles] sections.", + ); + } + }; + CommandResult::with_message_and_action( + format!("Switching to profile '{profile_name}'..."), + AppAction::SwitchProfile { + profile: profile_name, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn profile_without_arg_returns_usage() { + let mut app = create_test_app(); + let result = profile_switch(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /profile")); + } + + #[test] + fn profile_with_name_returns_switch_action() { + let mut app = create_test_app(); + let result = profile_switch(&mut app, Some("work")); + assert!(matches!( + result.action, + Some(AppAction::SwitchProfile { profile }) if profile == "work" + )); + } +} diff --git a/crates/tui/src/commands/groups/core/provider/mod.rs b/crates/tui/src/commands/groups/core/provider/mod.rs new file mode 100644 index 000000000..b4db7e979 --- /dev/null +++ b/crates/tui/src/commands/groups/core/provider/mod.rs @@ -0,0 +1,8 @@ +//! Provider command. +//! +//! This module separates the command handler from the implementation. + +pub mod provider_command; +pub mod provider_impl; +pub use provider_command::Provider; +pub use provider_impl::provider; diff --git a/crates/tui/src/commands/groups/core/provider/provider_command.rs b/crates/tui/src/commands/groups/core/provider/provider_command.rs new file mode 100644 index 000000000..9cfceda7a --- /dev/null +++ b/crates/tui/src/commands/groups/core/provider/provider_command.rs @@ -0,0 +1,79 @@ +//! Provider command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Provider; +impl Command for Provider { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "provider", + aliases: &[], + usage: "/provider [name] [model]", + description_id: MessageId::CmdProviderDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::core::provider::provider(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Provider; + let info = cmd.info(); + assert_eq!(info.name, "provider"); + assert!(info.aliases.is_empty()); + } + + #[test] + fn execute_without_args_shows_current() { + let mut app = test_app(); + let result = Provider.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + } + + #[test] + fn execute_with_unknown_provider_returns_error() { + let mut app = test_app(); + let result = Provider.execute(&mut app, Some("nonexistent")); + assert!(result.is_error); + } +} diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/groups/core/provider/provider_impl.rs similarity index 99% rename from crates/tui/src/commands/provider.rs rename to crates/tui/src/commands/groups/core/provider/provider_impl.rs index 911e6299b..313154644 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/groups/core/provider/provider_impl.rs @@ -10,7 +10,7 @@ use crate::config::{ }; use crate::tui::app::{App, AppAction}; -use super::CommandResult; +use crate::commands::CommandResult; /// Switch or view the current LLM backend. /// diff --git a/crates/tui/src/commands/groups/core/relay/mod.rs b/crates/tui/src/commands/groups/core/relay/mod.rs new file mode 100644 index 000000000..241d1f74e --- /dev/null +++ b/crates/tui/src/commands/groups/core/relay/mod.rs @@ -0,0 +1,5 @@ +//! Relay command. + +pub mod relay_command; +pub mod relay_impl; +pub use relay_command::Relay; diff --git a/crates/tui/src/commands/groups/core/relay/relay_command.rs b/crates/tui/src/commands/groups/core/relay/relay_command.rs new file mode 100644 index 000000000..1e344cab6 --- /dev/null +++ b/crates/tui/src/commands/groups/core/relay/relay_command.rs @@ -0,0 +1,47 @@ +//! Relay command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Relay; +impl Command for Relay { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "relay", + aliases: &["batonpass", "\u{63E5}\u{529B}"], + usage: "/relay [focus]", + description_id: MessageId::CmdRelayDescription, + } + } + + fn execute(&self, app: &mut App, arg: Option<&str>) -> CommandResult { + super::relay_impl::relay(app, arg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Relay.info(); + assert_eq!(info.name, "relay"); + assert_eq!(info.usage, "/relay [focus]"); + assert!(info.aliases.contains(&"batonpass")); + } + + #[test] + fn execute_sends_relay_instruction() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Relay.execute(&mut app, Some("next refactor step")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("handoff.md")); + let action = result.action.expect("expected send action"); + assert!( + matches!(action, crate::tui::app::AppAction::SendMessage(message) if message.contains("Session relay") && message.contains("next refactor step")) + ); + } +} diff --git a/crates/tui/src/commands/groups/core/relay/relay_impl.rs b/crates/tui/src/commands/groups/core/relay/relay_impl.rs new file mode 100644 index 000000000..83c159258 --- /dev/null +++ b/crates/tui/src/commands/groups/core/relay/relay_impl.rs @@ -0,0 +1,95 @@ +use std::fmt::Write as _; + +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn relay(app: &mut App, arg: Option<&str>) -> CommandResult { + let focus = arg.map(str::trim).filter(|value| !value.is_empty()); + let message = build_relay_instruction(app, focus); + CommandResult::with_message_and_action( + "Preparing session relay at .deepseek/handoff.md...", + AppAction::SendMessage(message), + ) +} + +fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { + match status { + crate::tools::plan::StepStatus::Pending => "pending", + crate::tools::plan::StepStatus::InProgress => "in_progress", + crate::tools::plan::StepStatus::Completed => "completed", + } +} + +fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "Create a compact session relay for a future CodeWhale thread." + ); + let _ = writeln!(out); + let _ = writeln!(out, "Write or update `.deepseek/handoff.md`."); + let _ = writeln!( + out, + "Keep the existing file path for compatibility, but title the artifact `# Session relay`." + ); + let _ = writeln!(out); + let _ = writeln!(out, "Current session snapshot:"); + let _ = writeln!(out, "- Workspace: {}", app.workspace.display()); + let _ = writeln!(out, "- Mode: {}", app.mode.label()); + let _ = writeln!(out, "- Model: {}", app.model_display_label()); + if let Some(focus) = focus { + let _ = writeln!(out, "- Requested relay focus: {focus}"); + } + if let Some(quarry) = app.hunt.quarry.as_deref() { + let _ = writeln!(out, "- Hunt quarry: {quarry}"); + } + if let Some(budget) = app.hunt.token_budget { + let _ = writeln!(out, "- Hunt token budget: {budget}"); + } + if let Ok(todos) = app.todos.try_lock() { + let snapshot = todos.snapshot(); + if !snapshot.items.is_empty() { + let _ = writeln!( + out, + "\nWork checklist (primary progress surface, {}% complete):", + snapshot.completion_pct + ); + for item in snapshot.items { + let _ = writeln!( + out, + "- #{} [{}] {}", + item.id, + item.status.as_str(), + item.content + ); + } + } + } else { + let _ = writeln!( + out, + "\nWork checklist: unavailable because the checklist is busy." + ); + } + if let Ok(plan) = app.plan_state.try_lock() { + let snapshot = plan.snapshot(); + if snapshot.explanation.is_some() || !snapshot.items.is_empty() { + let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); + if let Some(explanation) = snapshot.explanation.as_deref() { + let _ = writeln!(out, "- Explanation: {explanation}"); + } + for item in snapshot.items { + let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); + } + } + } else { + let _ = writeln!( + out, + "\nStrategy metadata: unavailable because plan state is busy." + ); + } + let _ = writeln!( + out, + "\nKeep it under about 900 words. After writing, report the path and the single next action." + ); + out +} diff --git a/crates/tui/src/commands/groups/core/subagents/mod.rs b/crates/tui/src/commands/groups/core/subagents/mod.rs new file mode 100644 index 000000000..bfb7c48f1 --- /dev/null +++ b/crates/tui/src/commands/groups/core/subagents/mod.rs @@ -0,0 +1,5 @@ +//! Subagents command. + +pub mod subagents_command; +pub mod subagents_impl; +pub use subagents_command::Subagents; diff --git a/crates/tui/src/commands/groups/core/subagents/subagents_command.rs b/crates/tui/src/commands/groups/core/subagents/subagents_command.rs new file mode 100644 index 000000000..32eb64b8d --- /dev/null +++ b/crates/tui/src/commands/groups/core/subagents/subagents_command.rs @@ -0,0 +1,72 @@ +//! Subagents command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Subagents; +impl Command for Subagents { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "subagents", + aliases: &["agents", "zhinengti"], + usage: "/subagents", + description_id: MessageId::CmdSubagentsDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::subagents_impl::subagents(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Subagents; + let info = cmd.info(); + assert_eq!(info.name, "subagents"); + assert!(info.aliases.contains(&"agents")); + } + + #[test] + fn execute_opens_subagent_view() { + let mut app = test_app(); + let result = Subagents.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + } +} diff --git a/crates/tui/src/commands/groups/core/subagents/subagents_impl.rs b/crates/tui/src/commands/groups/core/subagents/subagents_impl.rs new file mode 100644 index 000000000..6732745ea --- /dev/null +++ b/crates/tui/src/commands/groups/core/subagents/subagents_impl.rs @@ -0,0 +1,32 @@ +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::{App, AppAction}; +use crate::tui::views::{ModalKind, SubAgentsView, subagent_view_agents}; + +pub(crate) fn subagents(app: &mut App) -> CommandResult { + if app.view_stack.top_kind() != Some(ModalKind::SubAgents) { + let agents = subagent_view_agents(app, &app.subagent_cache); + app.view_stack.push(SubAgentsView::new(agents)); + } + app.status_message = Some(tr(app.ui_locale, MessageId::SubagentsFetching).to_string()); + CommandResult::action(AppAction::ListSubAgents) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + + #[test] + fn test_subagents_pushes_view_and_sets_status() { + let mut app = create_test_app(); + let result = subagents(&mut app); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::ListSubAgents))); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents)); + assert_eq!( + app.status_message, + Some("Fetching sub-agent status...".to_string()) + ); + } +} diff --git a/crates/tui/src/commands/groups/core/test_support.rs b/crates/tui/src/commands/groups/core/test_support.rs new file mode 100644 index 000000000..529af2a62 --- /dev/null +++ b/crates/tui/src/commands/groups/core/test_support.rs @@ -0,0 +1,76 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use tempfile::TempDir; + +use crate::config::Config; +use crate::tui::app::{App, TuiOptions}; + +pub(crate) struct SettingsPathGuard { + _tmp: TempDir, + previous: Option, + _lock: std::sync::MutexGuard<'static, ()>, +} + +impl SettingsPathGuard { + pub(crate) fn new() -> Self { + let lock = crate::test_support::lock_test_env(); + let tmp = TempDir::new().expect("settings tempdir"); + let config_path = tmp.path().join(".deepseek").join("config.toml"); + std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir"); + let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + std::env::set_var("DEEPSEEK_CONFIG_PATH", &config_path); + } + Self { + _tmp: tmp, + previous, + _lock: lock, + } + } +} + +impl Drop for SettingsPathGuard { + fn drop(&mut self) { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + if let Some(previous) = self.previous.take() { + std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); + } else { + std::env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } +} + +pub(crate) fn create_test_app() -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: PathBuf::from("/tmp/test-workspace"), + 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("/tmp/test-skills"), + 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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut app = App::new(options, &Config::default()); + app.ui_locale = crate::localization::Locale::En; + app.api_provider = crate::config::ApiProvider::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.model_ids_passthrough = false; + app +} diff --git a/crates/tui/src/commands/groups/core/workspace/mod.rs b/crates/tui/src/commands/groups/core/workspace/mod.rs new file mode 100644 index 000000000..0418167b1 --- /dev/null +++ b/crates/tui/src/commands/groups/core/workspace/mod.rs @@ -0,0 +1,5 @@ +//! Workspace command. + +pub mod workspace_command; +pub mod workspace_impl; +pub use workspace_command::Workspace; diff --git a/crates/tui/src/commands/groups/core/workspace/workspace_command.rs b/crates/tui/src/commands/groups/core/workspace/workspace_command.rs new file mode 100644 index 000000000..5ce31dbe8 --- /dev/null +++ b/crates/tui/src/commands/groups/core/workspace/workspace_command.rs @@ -0,0 +1,101 @@ +//! Workspace command. + +use crate::tui::app::App; + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; + +pub struct Workspace; +impl Command for Workspace { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "workspace", + aliases: &["cwd"], + usage: "/workspace [path]", + description_id: MessageId::CmdWorkspaceDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::workspace_impl::workspace_switch(app, args) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + use tempfile::tempdir; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn info_returns_metadata() { + let cmd = Workspace; + let info = cmd.info(); + assert_eq!(info.name, "workspace"); + assert!(info.aliases.contains(&"cwd")); + } + + #[test] + fn execute_without_args_shows_current() { + let mut app = test_app(); + let result = Workspace.execute(&mut app, None); + assert!(!result.is_error, "{:?}", result.message); + let msg = result.message.as_deref().unwrap_or(""); + assert!(msg.contains("workspace"), "workspace msg: {msg}"); + } + + #[test] + fn execute_with_valid_path_switches() { + let dir = tempdir().expect("temp dir"); + let mut app = test_app(); + let ws_arg = dir.path().to_str().expect("utf8"); + let result = Workspace.execute(&mut app, Some(ws_arg)); + assert!( + !result.is_error, + "workspace switch failed: {:?}", + result.message + ); + let Some(crate::tui::app::AppAction::SwitchWorkspace { workspace: new_ws }) = + &result.action + else { + panic!("expected SwitchWorkspace, got {:?}", result.action); + }; + assert!(new_ws.exists(), "workspace path should exist: {new_ws:?}"); + } + + #[test] + fn execute_with_nonexistent_path_returns_error() { + let mut app = test_app(); + let result = Workspace.execute(&mut app, Some("/nonexistent/path/that/does/not/exist")); + assert!(result.is_error, "expected error for nonexistent path"); + } +} diff --git a/crates/tui/src/commands/groups/core/workspace/workspace_impl.rs b/crates/tui/src/commands/groups/core/workspace/workspace_impl.rs new file mode 100644 index 000000000..38c2a8cb4 --- /dev/null +++ b/crates/tui/src/commands/groups/core/workspace/workspace_impl.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { + let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { + return CommandResult::message(format!("Current workspace: {}", app.workspace.display())); + }; + + let expanded = match expand_workspace_path(raw_path) { + Ok(path) => path, + Err(message) => return CommandResult::error(message), + }; + let candidate = if expanded.is_absolute() { + expanded + } else { + app.workspace.join(expanded) + }; + + if !candidate.exists() { + return CommandResult::error(format!("Workspace does not exist: {}", candidate.display())); + } + if !candidate.is_dir() { + return CommandResult::error(format!( + "Workspace is not a directory: {}", + candidate.display() + )); + } + + let workspace = candidate.canonicalize().unwrap_or(candidate); + CommandResult::with_message_and_action( + format!("Switching workspace to {}...", workspace.display()), + AppAction::SwitchWorkspace { workspace }, + ) +} + +fn expand_workspace_path(path: &str) -> Result { + if path == "~" { + return dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + let home = + dirs::home_dir().ok_or_else(|| "Could not resolve home directory".to_string())?; + return Ok(home.join(rest)); + } + Ok(PathBuf::from(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::core::test_support::create_test_app; + use tempfile::tempdir; + + #[test] + fn workspace_without_arg_shows_current_workspace() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, None); + let msg = result.message.expect("workspace should be shown"); + assert!(msg.contains("Current workspace:")); + assert!(msg.contains("/tmp/test-workspace")); + assert!(result.action.is_none()); + } + + #[test] + fn workspace_existing_absolute_dir_returns_switch_action() { + let mut app = create_test_app(); + let dir = tempdir().expect("temp dir"); + let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); + } + + #[test] + fn workspace_relative_dir_resolves_from_current_workspace() { + let root = tempdir().expect("temp dir"); + let child = root.path().join("child"); + std::fs::create_dir(&child).expect("child dir"); + let mut app = create_test_app(); + app.workspace = root.path().to_path_buf(); + + let result = workspace_switch(&mut app, Some("child")); + + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() + )); + } + + #[test] + fn workspace_rejects_missing_path() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, Some("definitely-missing")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn workspace_rejects_file_path() { + let root = tempdir().expect("temp dir"); + let file = root.path().join("file.txt"); + std::fs::write(&file, "not a directory").expect("test file"); + let mut app = create_test_app(); + + let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); + + assert!(result.is_error); + assert!(result.message.unwrap().contains("not a directory")); + } +} diff --git a/crates/tui/src/commands/groups/debug/balance/balance_command.rs b/crates/tui/src/commands/groups/debug/balance/balance_command.rs new file mode 100644 index 000000000..15ffa6356 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/balance/balance_command.rs @@ -0,0 +1,33 @@ +//! Balance command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Balance; +impl Command for Balance { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "balance", + aliases: &[], + usage: "/balance", + description_id: MessageId::CmdBalanceDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + crate::commands::groups::debug::balance::balance(app) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Balance.info(); + assert_eq!(info.name, "balance"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/balance.rs b/crates/tui/src/commands/groups/debug/balance/balance_impl.rs similarity index 96% rename from crates/tui/src/commands/balance.rs rename to crates/tui/src/commands/groups/debug/balance/balance_impl.rs index 45d941c9a..3dee9824e 100644 --- a/crates/tui/src/commands/balance.rs +++ b/crates/tui/src/commands/groups/debug/balance/balance_impl.rs @@ -7,7 +7,7 @@ use crate::config::ApiProvider; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; /// Query provider account balance / credits. pub fn balance(app: &mut App) -> CommandResult { diff --git a/crates/tui/src/commands/groups/debug/balance/mod.rs b/crates/tui/src/commands/groups/debug/balance/mod.rs new file mode 100644 index 000000000..bb6800a3d --- /dev/null +++ b/crates/tui/src/commands/groups/debug/balance/mod.rs @@ -0,0 +1,8 @@ +//! Balance command. +//! +//! This module separates the command handler from the implementation. + +pub mod balance_command; +pub mod balance_impl; +pub use balance_command::Balance; +pub use balance_impl::balance; diff --git a/crates/tui/src/commands/groups/debug/cache/cache_command.rs b/crates/tui/src/commands/groups/debug/cache/cache_command.rs new file mode 100644 index 000000000..fdcdccaf3 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cache/cache_command.rs @@ -0,0 +1,32 @@ +//! Cache command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Cache; +impl Command for Cache { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "cache", + aliases: &[], + usage: "/cache [count|inspect|stats|zones|warmup]", + description_id: MessageId::CmdCacheDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::cache_impl::cache(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Cache.info(); + assert_eq!(info.name, "cache"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/cache/cache_impl.rs b/crates/tui/src/commands/groups/debug/cache/cache_impl.rs new file mode 100644 index 000000000..ac7565611 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cache/cache_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn cache(app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::debug::debug_impl::cache(app, args) +} diff --git a/crates/tui/src/commands/groups/debug/cache/mod.rs b/crates/tui/src/commands/groups/debug/cache/mod.rs new file mode 100644 index 000000000..7a685c1dd --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cache/mod.rs @@ -0,0 +1,5 @@ +//! Cache command. + +pub mod cache_command; +pub mod cache_impl; +pub use cache_command::Cache; diff --git a/crates/tui/src/commands/groups/debug/context/context_command.rs b/crates/tui/src/commands/groups/debug/context/context_command.rs new file mode 100644 index 000000000..db2c1a3af --- /dev/null +++ b/crates/tui/src/commands/groups/debug/context/context_command.rs @@ -0,0 +1,32 @@ +//! Context command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Context; +impl Command for Context { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "context", + aliases: &["ctx"], + usage: "/context", + description_id: MessageId::CmdContextDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::context_impl::context(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Context.info(); + assert_eq!(info.name, "context"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/context/context_impl.rs b/crates/tui/src/commands/groups/debug/context/context_impl.rs new file mode 100644 index 000000000..9baefda4e --- /dev/null +++ b/crates/tui/src/commands/groups/debug/context/context_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn context(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::context(app) +} diff --git a/crates/tui/src/commands/groups/debug/context/mod.rs b/crates/tui/src/commands/groups/debug/context/mod.rs new file mode 100644 index 000000000..fe72d6738 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/context/mod.rs @@ -0,0 +1,5 @@ +//! Context command. + +pub mod context_command; +pub mod context_impl; +pub use context_command::Context; diff --git a/crates/tui/src/commands/groups/debug/cost/cost_command.rs b/crates/tui/src/commands/groups/debug/cost/cost_command.rs new file mode 100644 index 000000000..3dd484262 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cost/cost_command.rs @@ -0,0 +1,32 @@ +//! Cost command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Cost; +impl Command for Cost { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "cost", + aliases: &[], + usage: "/cost", + description_id: MessageId::CmdCostDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::cost_impl::cost(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Cost.info(); + assert_eq!(info.name, "cost"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/cost/cost_impl.rs b/crates/tui/src/commands/groups/debug/cost/cost_impl.rs new file mode 100644 index 000000000..2e824a052 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cost/cost_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn cost(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::cost(app) +} diff --git a/crates/tui/src/commands/groups/debug/cost/mod.rs b/crates/tui/src/commands/groups/debug/cost/mod.rs new file mode 100644 index 000000000..c99fc361f --- /dev/null +++ b/crates/tui/src/commands/groups/debug/cost/mod.rs @@ -0,0 +1,5 @@ +//! Cost command. + +pub mod cost_command; +pub mod cost_impl; +pub use cost_command::Cost; diff --git a/crates/tui/src/commands/debug.rs b/crates/tui/src/commands/groups/debug/debug_impl.rs similarity index 99% rename from crates/tui/src/commands/debug.rs rename to crates/tui/src/commands/groups/debug/debug_impl.rs index eee41bb59..4dd6d9542 100644 --- a/crates/tui/src/commands/debug.rs +++ b/crates/tui/src/commands/groups/debug/debug_impl.rs @@ -4,8 +4,8 @@ use std::time::Instant; -use super::CommandResult; use crate::client::{CacheWarmupKey, PromptInspection, inspect_prompt_for_request}; +use crate::commands::CommandResult; use crate::compaction::estimate_input_tokens_conservative; use crate::dependencies::{ExternalTool, Git}; use crate::localization::{Locale, MessageId, tr}; diff --git a/crates/tui/src/commands/groups/debug/diff/diff_command.rs b/crates/tui/src/commands/groups/debug/diff/diff_command.rs new file mode 100644 index 000000000..fe4624e28 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/diff/diff_command.rs @@ -0,0 +1,32 @@ +//! Diff command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Diff; +impl Command for Diff { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "diff", + aliases: &[], + usage: "/diff", + description_id: MessageId::CmdDiffDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::diff_impl::diff(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Diff.info(); + assert_eq!(info.name, "diff"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/diff/diff_impl.rs b/crates/tui/src/commands/groups/debug/diff/diff_impl.rs new file mode 100644 index 000000000..13daf80c2 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/diff/diff_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn diff(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::diff(app) +} diff --git a/crates/tui/src/commands/groups/debug/diff/mod.rs b/crates/tui/src/commands/groups/debug/diff/mod.rs new file mode 100644 index 000000000..322de4c54 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/diff/mod.rs @@ -0,0 +1,5 @@ +//! Diff command. + +pub mod diff_command; +pub mod diff_impl; +pub use diff_command::Diff; diff --git a/crates/tui/src/commands/groups/debug/edit/edit_command.rs b/crates/tui/src/commands/groups/debug/edit/edit_command.rs new file mode 100644 index 000000000..2c336ee36 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/edit/edit_command.rs @@ -0,0 +1,32 @@ +//! Edit command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Edit; +impl Command for Edit { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "edit", + aliases: &[], + usage: "/edit", + description_id: MessageId::CmdEditDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::edit_impl::edit(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Edit.info(); + assert_eq!(info.name, "edit"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/edit/edit_impl.rs b/crates/tui/src/commands/groups/debug/edit/edit_impl.rs new file mode 100644 index 000000000..9d7fb71e8 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/edit/edit_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn edit(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::edit(app) +} diff --git a/crates/tui/src/commands/groups/debug/edit/mod.rs b/crates/tui/src/commands/groups/debug/edit/mod.rs new file mode 100644 index 000000000..90e26f249 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/edit/mod.rs @@ -0,0 +1,5 @@ +//! Edit command. + +pub mod edit_command; +pub mod edit_impl; +pub use edit_command::Edit; diff --git a/crates/tui/src/commands/groups/debug/mod.rs b/crates/tui/src/commands/groups/debug/mod.rs new file mode 100644 index 000000000..6f3e27193 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/mod.rs @@ -0,0 +1,51 @@ +//! Debug commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod balance; +pub(crate) mod cache; +pub(crate) mod context; +pub(crate) mod cost; +pub(crate) mod debug_impl; +pub(crate) mod diff; +pub(crate) mod edit; +pub(crate) mod retry; +pub(crate) mod system; +pub(crate) mod tokens; +pub(crate) mod translate; +pub(crate) mod undo; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::balance::Balance; +use self::cache::Cache; +use self::context::Context; +use self::cost::Cost; +use self::diff::Diff; +use self::edit::Edit; +use self::retry::Retry; +use self::system::System; +use self::tokens::Tokens; +use self::translate::Translate; +use self::undo::Undo; + +pub struct DebugCommands; +impl CommandGroup for DebugCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(Translate), + Box::new(Tokens), + Box::new(Cost), + Box::new(Balance), + Box::new(Cache), + Box::new(System), + Box::new(Context), + Box::new(Edit), + Box::new(Diff), + Box::new(Undo), + Box::new(Retry), + ] + } +} diff --git a/crates/tui/src/commands/groups/debug/retry/mod.rs b/crates/tui/src/commands/groups/debug/retry/mod.rs new file mode 100644 index 000000000..f9db61496 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/retry/mod.rs @@ -0,0 +1,5 @@ +//! Retry command. + +pub mod retry_command; +pub mod retry_impl; +pub use retry_command::Retry; diff --git a/crates/tui/src/commands/groups/debug/retry/retry_command.rs b/crates/tui/src/commands/groups/debug/retry/retry_command.rs new file mode 100644 index 000000000..a39bd0a14 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/retry/retry_command.rs @@ -0,0 +1,32 @@ +//! Retry command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Retry; +impl Command for Retry { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "retry", + aliases: &["chongshi"], + usage: "/retry", + description_id: MessageId::CmdRetryDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::retry_impl::retry(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Retry.info(); + assert_eq!(info.name, "retry"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/retry/retry_impl.rs b/crates/tui/src/commands/groups/debug/retry/retry_impl.rs new file mode 100644 index 000000000..a8a93a78f --- /dev/null +++ b/crates/tui/src/commands/groups/debug/retry/retry_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn retry(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::retry(app) +} diff --git a/crates/tui/src/commands/groups/debug/system/mod.rs b/crates/tui/src/commands/groups/debug/system/mod.rs new file mode 100644 index 000000000..3ccf33acd --- /dev/null +++ b/crates/tui/src/commands/groups/debug/system/mod.rs @@ -0,0 +1,5 @@ +//! System command. + +pub mod system_command; +pub mod system_impl; +pub use system_command::System; diff --git a/crates/tui/src/commands/groups/debug/system/system_command.rs b/crates/tui/src/commands/groups/debug/system/system_command.rs new file mode 100644 index 000000000..e46e1d890 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/system/system_command.rs @@ -0,0 +1,32 @@ +//! System command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct System; +impl Command for System { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "system", + aliases: &["xitong"], + usage: "/system", + description_id: MessageId::CmdSystemDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::system_impl::system_prompt(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = System.info(); + assert_eq!(info.name, "system"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/system/system_impl.rs b/crates/tui/src/commands/groups/debug/system/system_impl.rs new file mode 100644 index 000000000..a0bf46dee --- /dev/null +++ b/crates/tui/src/commands/groups/debug/system/system_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn system_prompt(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::system_prompt(app) +} diff --git a/crates/tui/src/commands/groups/debug/tokens/mod.rs b/crates/tui/src/commands/groups/debug/tokens/mod.rs new file mode 100644 index 000000000..1dd0d95f0 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/tokens/mod.rs @@ -0,0 +1,5 @@ +//! Tokens command. + +pub mod tokens_command; +pub mod tokens_impl; +pub use tokens_command::Tokens; diff --git a/crates/tui/src/commands/groups/debug/tokens/tokens_command.rs b/crates/tui/src/commands/groups/debug/tokens/tokens_command.rs new file mode 100644 index 000000000..d12616196 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/tokens/tokens_command.rs @@ -0,0 +1,32 @@ +//! Tokens command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Tokens; +impl Command for Tokens { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "tokens", + aliases: &[], + usage: "/tokens", + description_id: MessageId::CmdTokensDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::tokens_impl::tokens(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Tokens.info(); + assert_eq!(info.name, "tokens"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/debug/tokens/tokens_impl.rs b/crates/tui/src/commands/groups/debug/tokens/tokens_impl.rs new file mode 100644 index 000000000..8adf8620e --- /dev/null +++ b/crates/tui/src/commands/groups/debug/tokens/tokens_impl.rs @@ -0,0 +1,6 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn tokens(app: &mut App) -> CommandResult { + crate::commands::groups::debug::debug_impl::tokens(app) +} diff --git a/crates/tui/src/commands/groups/debug/translate/mod.rs b/crates/tui/src/commands/groups/debug/translate/mod.rs new file mode 100644 index 000000000..e228881a0 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/translate/mod.rs @@ -0,0 +1,5 @@ +//! Translate command. + +pub mod translate_command; +pub mod translate_impl; +pub use translate_command::Translate; diff --git a/crates/tui/src/commands/groups/debug/translate/translate_command.rs b/crates/tui/src/commands/groups/debug/translate/translate_command.rs new file mode 100644 index 000000000..b315afce1 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/translate/translate_command.rs @@ -0,0 +1,45 @@ +//! Translate command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Translate; +impl Command for Translate { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "translate", + aliases: &["translation", "transale"], + usage: "/translate", + description_id: MessageId::CmdTranslateDescription, + } + } + + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::translate_impl::translate(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Translate.info(); + assert_eq!(info.name, "translate"); + assert_eq!(info.usage, "/translate"); + assert!(info.aliases.contains(&"translation")); + } + + #[test] + fn execute_toggles_translation() { + let mut app = crate::commands::groups::test_support::test_app(); + app.translation_enabled = false; + let result = Translate.execute(&mut app, None); + assert!(!result.is_error); + assert!(app.translation_enabled); + assert!(result.message.is_some()); + } +} diff --git a/crates/tui/src/commands/groups/debug/translate/translate_impl.rs b/crates/tui/src/commands/groups/debug/translate/translate_impl.rs new file mode 100644 index 000000000..c5fb35e6e --- /dev/null +++ b/crates/tui/src/commands/groups/debug/translate/translate_impl.rs @@ -0,0 +1,13 @@ +use crate::commands::CommandResult; +use crate::localization::{MessageId, tr}; +use crate::tui::app::App; + +pub(crate) fn translate(app: &mut App) -> CommandResult { + app.translation_enabled = !app.translation_enabled; + let locale = app.ui_locale; + if app.translation_enabled { + CommandResult::message(tr(locale, MessageId::CmdTranslateOn)) + } else { + CommandResult::message(tr(locale, MessageId::CmdTranslateOff)) + } +} diff --git a/crates/tui/src/commands/groups/debug/undo/mod.rs b/crates/tui/src/commands/groups/debug/undo/mod.rs new file mode 100644 index 000000000..7357700fd --- /dev/null +++ b/crates/tui/src/commands/groups/debug/undo/mod.rs @@ -0,0 +1,5 @@ +//! Undo command. + +pub mod undo_command; +pub mod undo_impl; +pub use undo_command::Undo; diff --git a/crates/tui/src/commands/groups/debug/undo/undo_command.rs b/crates/tui/src/commands/groups/debug/undo/undo_command.rs new file mode 100644 index 000000000..12af713a6 --- /dev/null +++ b/crates/tui/src/commands/groups/debug/undo/undo_command.rs @@ -0,0 +1,42 @@ +//! Undo command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Undo; +impl Command for Undo { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "undo", + aliases: &[], + usage: "/undo", + description_id: MessageId::CmdUndoDescription, + } + } + + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::undo_impl::undo(app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Undo.info(); + assert_eq!(info.name, "undo"); + assert_eq!(info.usage, "/undo"); + } + + #[test] + fn execute_without_history_returns_message() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Undo.execute(&mut app, None); + assert!(!result.is_error); + assert!(result.message.is_some()); + } +} diff --git a/crates/tui/src/commands/groups/debug/undo/undo_impl.rs b/crates/tui/src/commands/groups/debug/undo/undo_impl.rs new file mode 100644 index 000000000..5380639af --- /dev/null +++ b/crates/tui/src/commands/groups/debug/undo/undo_impl.rs @@ -0,0 +1,15 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub(crate) fn undo(app: &mut App) -> CommandResult { + let result = crate::commands::groups::debug::debug_impl::patch_undo(app); + if result.message.as_deref().is_none_or(|message| { + message.starts_with("No snapshots found") + || message.starts_with("No tool or pre-turn") + || message.starts_with("Snapshot repo") + }) { + crate::commands::groups::debug::debug_impl::undo_conversation(app) + } else { + result + } +} diff --git a/crates/tui/src/commands/groups/memory/attach/attach_command.rs b/crates/tui/src/commands/groups/memory/attach/attach_command.rs new file mode 100644 index 000000000..f10f5dbc7 --- /dev/null +++ b/crates/tui/src/commands/groups/memory/attach/attach_command.rs @@ -0,0 +1,33 @@ +//! Attach command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Attach; +impl Command for Attach { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "attach", + aliases: &["image", "media", "fujian"], + usage: "/attach [description]", + description_id: MessageId::CmdAttachDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::memory::attach::attach(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Attach.info(); + assert_eq!(info.name, "attach"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/attachment.rs b/crates/tui/src/commands/groups/memory/attach/attach_impl.rs similarity index 99% rename from crates/tui/src/commands/attachment.rs rename to crates/tui/src/commands/groups/memory/attach/attach_impl.rs index 2f205381c..f9f33a384 100644 --- a/crates/tui/src/commands/attachment.rs +++ b/crates/tui/src/commands/groups/memory/attach/attach_impl.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use super::CommandResult; +use crate::commands::CommandResult; use crate::tui::app::App; pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult { diff --git a/crates/tui/src/commands/groups/memory/attach/mod.rs b/crates/tui/src/commands/groups/memory/attach/mod.rs new file mode 100644 index 000000000..c533d5b11 --- /dev/null +++ b/crates/tui/src/commands/groups/memory/attach/mod.rs @@ -0,0 +1,8 @@ +//! Attach command. +//! +//! This module separates the command handler from the implementation. + +pub mod attach_command; +pub mod attach_impl; +pub use attach_command::Attach; +pub use attach_impl::attach; diff --git a/crates/tui/src/commands/groups/memory/memory/memory_command.rs b/crates/tui/src/commands/groups/memory/memory/memory_command.rs new file mode 100644 index 000000000..5ede052ad --- /dev/null +++ b/crates/tui/src/commands/groups/memory/memory/memory_command.rs @@ -0,0 +1,33 @@ +//! Memory command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Memory; +impl Command for Memory { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "memory", + aliases: &[], + usage: "/memory [show|path|clear|edit|help]", + description_id: MessageId::CmdMemoryDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::memory::memory::memory(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Memory.info(); + assert_eq!(info.name, "memory"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/memory.rs b/crates/tui/src/commands/groups/memory/memory/memory_impl.rs similarity index 99% rename from crates/tui/src/commands/memory.rs rename to crates/tui/src/commands/groups/memory/memory/memory_impl.rs index 0c9af71a6..f20705506 100644 --- a/crates/tui/src/commands/memory.rs +++ b/crates/tui/src/commands/groups/memory/memory/memory_impl.rs @@ -20,7 +20,7 @@ use std::fs; use std::path::Path; -use super::CommandResult; +use crate::commands::CommandResult; use crate::tui::app::App; const MEMORY_USAGE: &str = "/memory [show|path|clear|edit|help]"; diff --git a/crates/tui/src/commands/groups/memory/memory/mod.rs b/crates/tui/src/commands/groups/memory/memory/mod.rs new file mode 100644 index 000000000..74161fde8 --- /dev/null +++ b/crates/tui/src/commands/groups/memory/memory/mod.rs @@ -0,0 +1,8 @@ +//! Memory command. +//! +//! This module separates the command handler from the implementation. + +pub mod memory_command; +pub mod memory_impl; +pub use memory_command::Memory; +pub use memory_impl::memory; diff --git a/crates/tui/src/commands/groups/memory/mod.rs b/crates/tui/src/commands/groups/memory/mod.rs new file mode 100644 index 000000000..b4681157a --- /dev/null +++ b/crates/tui/src/commands/groups/memory/mod.rs @@ -0,0 +1,24 @@ +//! Memory commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod attach; +// The `/memory` command intentionally has the same name as the memory group. +#[allow(clippy::module_inception)] +pub(crate) mod memory; +pub(crate) mod note; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::attach::Attach; +use self::memory::Memory; +use self::note::Note; + +pub struct MemoryCommands; +impl CommandGroup for MemoryCommands { + fn commands(&self) -> Vec> { + vec![Box::new(Note), Box::new(Memory), Box::new(Attach)] + } +} diff --git a/crates/tui/src/commands/groups/memory/note/mod.rs b/crates/tui/src/commands/groups/memory/note/mod.rs new file mode 100644 index 000000000..2107bb30a --- /dev/null +++ b/crates/tui/src/commands/groups/memory/note/mod.rs @@ -0,0 +1,8 @@ +//! Note command. +//! +//! This module separates the command handler from the implementation. + +pub mod note_command; +pub mod note_impl; +pub use note_command::Note; +pub use note_impl::note; diff --git a/crates/tui/src/commands/groups/memory/note/note_command.rs b/crates/tui/src/commands/groups/memory/note/note_command.rs new file mode 100644 index 000000000..cc07fae75 --- /dev/null +++ b/crates/tui/src/commands/groups/memory/note/note_command.rs @@ -0,0 +1,33 @@ +//! Note command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Note; +impl Command for Note { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "note", + aliases: &[], + usage: "/note ", + description_id: MessageId::CmdNoteDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::memory::note::note(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Note.info(); + assert_eq!(info.name, "note"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/note.rs b/crates/tui/src/commands/groups/memory/note/note_impl.rs similarity index 99% rename from crates/tui/src/commands/note.rs rename to crates/tui/src/commands/groups/memory/note/note_impl.rs index 6efe44134..5074563a8 100644 --- a/crates/tui/src/commands/note.rs +++ b/crates/tui/src/commands/groups/memory/note/note_impl.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use super::CommandResult; +use crate::commands::CommandResult; const USAGE: &str = "/note | /note add | /note list | /note show | /note edit | /note remove | /note clear | /note path"; diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs new file mode 100644 index 000000000..efeadc80c --- /dev/null +++ b/crates/tui/src/commands/groups/mod.rs @@ -0,0 +1,40 @@ +//! Command group modules. +//! +//! Each group module registers its commands into the registry via the +//! `CommandGroup` trait. `commands/mod.rs` only calls `all_command_groups()` +//! — it never names individual groups. +//! +//! Adding a new group: +//! 1. Create `groups/my_group/` directory with `mod.rs` barrel + command files +//! 2. Add `mod my_group;` below +//! 3. Add `&my_group::MyGroupCommands` to the `all_command_groups()` vec + +pub(crate) mod config; +pub(crate) mod core; +pub(crate) mod debug; +pub(crate) mod memory; +pub(crate) mod project; +pub(crate) mod session; +pub(crate) mod skills; +#[cfg(test)] +pub(crate) mod test_support; +pub(crate) mod utility; + +use crate::commands::traits::CommandGroup; + +/// Returns all registered command groups. +/// +/// This is the single source of truth for which groups exist. Callers +/// iterate this list without knowing which groups are present. +pub fn all_command_groups() -> Vec<&'static dyn CommandGroup> { + vec![ + &core::CoreCommands, + &session::SessionCommands, + &config::ConfigCommands, + &debug::DebugCommands, + &project::ProjectCommands, + &skills::SkillsCommands, + &memory::MemoryCommands, + &utility::UtilityCommands, + ] +} diff --git a/crates/tui/src/commands/groups/project/change/change_command.rs b/crates/tui/src/commands/groups/project/change/change_command.rs new file mode 100644 index 000000000..3661e791a --- /dev/null +++ b/crates/tui/src/commands/groups/project/change/change_command.rs @@ -0,0 +1,21 @@ +//! Change command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Change; +impl Command for Change { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "change", + aliases: &[], + usage: "/change ", + description_id: MessageId::CmdChangeDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::project::change::change(app, args) + } +} diff --git a/crates/tui/src/commands/change.rs b/crates/tui/src/commands/groups/project/change/change_impl.rs similarity index 99% rename from crates/tui/src/commands/change.rs rename to crates/tui/src/commands/groups/project/change/change_impl.rs index 0f9c3dbd7..edbe01b9e 100644 --- a/crates/tui/src/commands/change.rs +++ b/crates/tui/src/commands/groups/project/change/change_impl.rs @@ -13,13 +13,13 @@ use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::{App, AppAction}; -use super::CommandResult; +use crate::commands::CommandResult; /// Maximum length of the changelog excerpt we'll show inline (characters). /// If the changelog section exceeds this, we truncate and show a notice. /// 4096 chars is large enough for most version entries. const MAX_INLINE_CHANGELOG_CHARS: usize = 4096; -const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../CHANGELOG.md"); +const DEEPSEEK_TUI_CHANGELOG: &str = include_str!("../../../../../CHANGELOG.md"); /// Execute the `/change` command. /// diff --git a/crates/tui/src/commands/groups/project/change/mod.rs b/crates/tui/src/commands/groups/project/change/mod.rs new file mode 100644 index 000000000..d22cb9d16 --- /dev/null +++ b/crates/tui/src/commands/groups/project/change/mod.rs @@ -0,0 +1,8 @@ +//! Change command. +//! +//! This module separates the command handler from the implementation. + +pub mod change_command; +pub mod change_impl; +pub use change_command::Change; +pub use change_impl::change; diff --git a/crates/tui/src/commands/groups/project/goal/goal_command.rs b/crates/tui/src/commands/groups/project/goal/goal_command.rs new file mode 100644 index 000000000..bab57eb9d --- /dev/null +++ b/crates/tui/src/commands/groups/project/goal/goal_command.rs @@ -0,0 +1,21 @@ +//! Goal command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Goal; +impl Command for Goal { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "goal", + aliases: &["hunt", "mubiao", "\u{72e9}\u{730e}"], + usage: "/goal [start|show|close ]", + description_id: MessageId::CmdGoalDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::project::goal::hunt(app, args) + } +} diff --git a/crates/tui/src/commands/goal.rs b/crates/tui/src/commands/groups/project/goal/goal_impl.rs similarity index 99% rename from crates/tui/src/commands/goal.rs rename to crates/tui/src/commands/groups/project/goal/goal_impl.rs index ce3858b58..4c3871384 100644 --- a/crates/tui/src/commands/goal.rs +++ b/crates/tui/src/commands/groups/project/goal/goal_impl.rs @@ -4,7 +4,7 @@ use std::io::Write; use crate::tui::app::{App, AppAction, HuntVerdict}; -use super::CommandResult; +use crate::commands::CommandResult; /// Declare, show, or close a hunt pub fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult { diff --git a/crates/tui/src/commands/groups/project/goal/mod.rs b/crates/tui/src/commands/groups/project/goal/mod.rs new file mode 100644 index 000000000..29a255735 --- /dev/null +++ b/crates/tui/src/commands/groups/project/goal/mod.rs @@ -0,0 +1,8 @@ +//! Goal command. +//! +//! This module separates the command handler from the implementation. + +pub mod goal_command; +pub mod goal_impl; +pub use goal_command::Goal; +pub use goal_impl::hunt; diff --git a/crates/tui/src/commands/groups/project/init/init_command.rs b/crates/tui/src/commands/groups/project/init/init_command.rs new file mode 100644 index 000000000..ff74914d5 --- /dev/null +++ b/crates/tui/src/commands/groups/project/init/init_command.rs @@ -0,0 +1,21 @@ +//! Init command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Init; +impl Command for Init { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "init", + aliases: &[], + usage: "/init", + description_id: MessageId::CmdInitDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + crate::commands::groups::project::init::init(app) + } +} diff --git a/crates/tui/src/commands/init.rs b/crates/tui/src/commands/groups/project/init/init_impl.rs similarity index 99% rename from crates/tui/src/commands/init.rs rename to crates/tui/src/commands/groups/project/init/init_impl.rs index 7ca53ec92..890a82af7 100644 --- a/crates/tui/src/commands/init.rs +++ b/crates/tui/src/commands/groups/project/init/init_impl.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; /// Generate an AGENTS.md file for the current project pub fn init(app: &mut App) -> CommandResult { diff --git a/crates/tui/src/commands/groups/project/init/mod.rs b/crates/tui/src/commands/groups/project/init/mod.rs new file mode 100644 index 000000000..45ed59dce --- /dev/null +++ b/crates/tui/src/commands/groups/project/init/mod.rs @@ -0,0 +1,8 @@ +//! Init command. +//! +//! This module separates the command handler from the implementation. + +pub mod init_command; +pub mod init_impl; +pub use init_command::Init; +pub use init_impl::init; diff --git a/crates/tui/src/commands/groups/project/lsp/lsp_command.rs b/crates/tui/src/commands/groups/project/lsp/lsp_command.rs new file mode 100644 index 000000000..e21ff305b --- /dev/null +++ b/crates/tui/src/commands/groups/project/lsp/lsp_command.rs @@ -0,0 +1,22 @@ +//! Lsp command. + +use super::lsp_impl::lsp_command; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Lsp; +impl Command for Lsp { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "lsp", + aliases: &[], + usage: "/lsp ", + description_id: MessageId::CmdLspDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + lsp_command(app, args) + } +} diff --git a/crates/tui/src/commands/groups/project/lsp/lsp_impl.rs b/crates/tui/src/commands/groups/project/lsp/lsp_impl.rs new file mode 100644 index 000000000..873895899 --- /dev/null +++ b/crates/tui/src/commands/groups/project/lsp/lsp_impl.rs @@ -0,0 +1,31 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + // Access lsp_manager config through the App's engine handle + 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`." + )), + } +} diff --git a/crates/tui/src/commands/groups/project/lsp/mod.rs b/crates/tui/src/commands/groups/project/lsp/mod.rs new file mode 100644 index 000000000..ba6b72c55 --- /dev/null +++ b/crates/tui/src/commands/groups/project/lsp/mod.rs @@ -0,0 +1,5 @@ +//! Lsp command. + +pub mod lsp_command; +pub mod lsp_impl; +pub use lsp_command::Lsp; diff --git a/crates/tui/src/commands/groups/project/mod.rs b/crates/tui/src/commands/groups/project/mod.rs new file mode 100644 index 000000000..649d65da7 --- /dev/null +++ b/crates/tui/src/commands/groups/project/mod.rs @@ -0,0 +1,32 @@ +//! Project commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod change; +pub(crate) mod goal; +pub(crate) mod init; +pub(crate) mod lsp; +pub(crate) mod share; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::change::Change; +use self::goal::Goal; +use self::init::Init; +use self::lsp::Lsp; +use self::share::Share; + +pub struct ProjectCommands; +impl CommandGroup for ProjectCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(Change), + Box::new(Init), + Box::new(Lsp), + Box::new(Share), + Box::new(Goal), + ] + } +} diff --git a/crates/tui/src/commands/groups/project/share/mod.rs b/crates/tui/src/commands/groups/project/share/mod.rs new file mode 100644 index 000000000..ccbdabb26 --- /dev/null +++ b/crates/tui/src/commands/groups/project/share/mod.rs @@ -0,0 +1,5 @@ +//! Share command. + +pub mod share_command; +pub mod share_impl; +pub use share_command::Share; diff --git a/crates/tui/src/commands/groups/project/share/share_command.rs b/crates/tui/src/commands/groups/project/share/share_command.rs new file mode 100644 index 000000000..b6b7add60 --- /dev/null +++ b/crates/tui/src/commands/groups/project/share/share_command.rs @@ -0,0 +1,34 @@ +//! Share command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Share; +impl Command for Share { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "share", + aliases: &[], + usage: "/share [path]", + description_id: MessageId::CmdShareDescription, + } + } + + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::share_impl::share(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Share.info(); + assert_eq!(info.name, "share"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/project/share/share_impl.rs b/crates/tui/src/commands/groups/project/share/share_impl.rs new file mode 100644 index 000000000..b83df856b --- /dev/null +++ b/crates/tui/src/commands/groups/project/share/share_impl.rs @@ -0,0 +1,104 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +/// Share the current session as a web URL. +pub(crate) fn share(app: &mut App, arg: Option<&str>) -> CommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + + match raw { + "" => do_share(app), + "help" | "--help" | "-h" => CommandResult::message( + "/share - Export the current session as a shareable web URL.\n\ + \n\ + Usage:\n\ + /share Export and upload the current session\n\ + \n\ + The session transcript is rendered as static HTML and uploaded\n\ + to a GitHub Gist using the `gh` CLI. The Gist URL is displayed\n\ + so you can paste it into Slack, GitHub, Twitter, etc." + .to_string(), + ), + _ => CommandResult::error(format!( + "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." + )), + } +} + +fn do_share(app: &mut App) -> CommandResult { + if app.history.is_empty() { + return CommandResult::error("Nothing to share. The current session is empty."); + } + + let history_len = app.history.len(); + let model = &app.model; + let mode = app.mode.label(); + + CommandResult::with_message_and_action( + format!( + "Exporting {history_len} cell(s) from {model} ({mode}) session...\n\n\ + The session will be rendered as static HTML and uploaded to a GitHub Gist.\n\ + This requires the `gh` CLI to be installed and authenticated." + ), + AppAction::ShareSession { + history_len, + model: model.clone(), + mode: mode.to_string(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::test_support::test_app; + use crate::tui::history::HistoryCell; + + #[test] + fn share_empty_session_returns_error() { + let mut app = test_app(); + + let result = share(&mut app, None); + + assert!(result.is_error); + assert!(result.message.unwrap().contains("Nothing to share")); + assert!(result.action.is_none()); + } + + #[test] + fn share_help_returns_usage() { + let mut app = test_app(); + + let result = share(&mut app, Some("help")); + + let msg = result.message.expect("usage message"); + assert!(msg.contains("Usage:")); + assert!(msg.contains("/share")); + assert!(result.action.is_none()); + } + + #[test] + fn share_with_history_returns_share_action() { + let mut app = test_app(); + app.history.push(HistoryCell::User { + content: "hello".to_string(), + }); + + let result = share(&mut app, None); + + assert!(result.message.is_some()); + assert!(matches!( + result.action, + Some(AppAction::ShareSession { history_len: 1, .. }) + )); + } + + #[test] + fn share_unknown_argument_returns_error() { + let mut app = test_app(); + + let result = share(&mut app, Some("bogus")); + + assert!(result.is_error); + assert!(result.message.unwrap().contains("Unknown /share argument")); + } +} diff --git a/crates/tui/src/commands/groups/session/compact/compact_command.rs b/crates/tui/src/commands/groups/session/compact/compact_command.rs new file mode 100644 index 000000000..07b4cb1b3 --- /dev/null +++ b/crates/tui/src/commands/groups/session/compact/compact_command.rs @@ -0,0 +1,32 @@ +//! Compact command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Compact; +impl Command for Compact { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "compact", + aliases: &["yasuo"], + usage: "/compact", + description_id: MessageId::CmdCompactDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::compact_impl::compact(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Compact.info(); + assert_eq!(info.name, "compact"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/compact/compact_impl.rs b/crates/tui/src/commands/groups/session/compact/compact_impl.rs new file mode 100644 index 000000000..41200b637 --- /dev/null +++ b/crates/tui/src/commands/groups/session/compact/compact_impl.rs @@ -0,0 +1,29 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn compact(_app: &mut App) -> CommandResult { + CommandResult::with_message_and_action( + "Context compaction triggered...".to_string(), + AppAction::CompactContext, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use tempfile::TempDir; + + #[test] + fn test_compact_toggles_state() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = compact(&mut app); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("compaction") || msg.contains("Compact")); + assert!(matches!(result.action, Some(AppAction::CompactContext))); + } +} diff --git a/crates/tui/src/commands/groups/session/compact/mod.rs b/crates/tui/src/commands/groups/session/compact/mod.rs new file mode 100644 index 000000000..8d0454a94 --- /dev/null +++ b/crates/tui/src/commands/groups/session/compact/mod.rs @@ -0,0 +1,5 @@ +//! Compact command. + +pub mod compact_command; +pub mod compact_impl; +pub use compact_command::Compact; diff --git a/crates/tui/src/commands/groups/session/export/export_command.rs b/crates/tui/src/commands/groups/session/export/export_command.rs new file mode 100644 index 000000000..01cea2d5d --- /dev/null +++ b/crates/tui/src/commands/groups/session/export/export_command.rs @@ -0,0 +1,32 @@ +//! Export command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Export; +impl Command for Export { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "export", + aliases: &["daochu"], + usage: "/export [path]", + description_id: MessageId::CmdExportDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::export_impl::export(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Export.info(); + assert_eq!(info.name, "export"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/export/export_impl.rs b/crates/tui/src/commands/groups/session/export/export_impl.rs new file mode 100644 index 000000000..df501a65b --- /dev/null +++ b/crates/tui/src/commands/groups/session/export/export_impl.rs @@ -0,0 +1,134 @@ +use std::fmt::Write; +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::tui::app::App; +use crate::tui::history::HistoryCell; + +pub(crate) fn export(app: &mut App, path: Option<&str>) -> CommandResult { + let export_path = path.map_or_else( + || { + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + PathBuf::from(format!("chat_export_{timestamp}.md")) + }, + PathBuf::from, + ); + + let mut content = String::new(); + content.push_str("# Chat Export\n\n"); + let _ = write!( + content, + "**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n", + app.model, + app.workspace.display(), + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ); + + for cell in &app.history { + let (role, body) = match cell { + HistoryCell::User { content } => ("**You:**", content.clone()), + HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()), + HistoryCell::System { content } => ("*System:*", content.clone()), + HistoryCell::Error { message, severity } => match severity { + crate::error_taxonomy::ErrorSeverity::Warning => ("**Warning:**", message.clone()), + crate::error_taxonomy::ErrorSeverity::Info => ("*Info:*", message.clone()), + _ => ("**Error:**", message.clone()), + }, + HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), + HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), + HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)), + HistoryCell::ArchivedContext { + level, + range, + summary, + .. + } => ( + "**Archived Context:**", + format!("L{level} [{range}]: {summary}"), + ), + }; + + let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim()); + } + + match std::fs::write(&export_path, content) { + Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())), + Err(e) => CommandResult::error(format!("Failed to export: {e}")), + } +} + +fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String { + tool.lines(width) + .into_iter() + .map(line_to_string) + .collect::>() + .join("\n") +} + +fn render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String { + cell.lines(width) + .into_iter() + .map(line_to_string) + .collect::>() + .join("\n") +} + +fn line_to_string(line: ratatui::text::Line<'static>) -> String { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use tempfile::TempDir; + + #[test] + fn test_export_crees_markdown_file() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.history.push(HistoryCell::User { + content: "Hello".to_string(), + }); + app.history.push(HistoryCell::Assistant { + content: "Hi there".to_string(), + streaming: false, + }); + + let export_path = tmpdir.path().join("export.md"); + let result = export(&mut app, Some(export_path.to_str().unwrap())); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Exported to")); + assert!(export_path.exists()); + + let content = std::fs::read_to_string(&export_path).unwrap(); + assert!(content.contains("# Chat Export")); + assert!(content.contains("**Model:**")); + assert!(content.contains("**You:**")); + assert!(content.contains("**Assistant:**")); + } + + #[test] + fn test_export_with_default_path() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = export(&mut app, None); + + assert!(result.message.is_some()); + let entries: Vec<_> = std::fs::read_dir(".") + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_")) + .collect(); + for entry in &entries { + let _ = std::fs::remove_file(entry.path()); + } + assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to")); + } +} diff --git a/crates/tui/src/commands/groups/session/export/mod.rs b/crates/tui/src/commands/groups/session/export/mod.rs new file mode 100644 index 000000000..36cd90147 --- /dev/null +++ b/crates/tui/src/commands/groups/session/export/mod.rs @@ -0,0 +1,5 @@ +//! Export command. + +pub mod export_command; +pub mod export_impl; +pub use export_command::Export; diff --git a/crates/tui/src/commands/groups/session/fork/fork_command.rs b/crates/tui/src/commands/groups/session/fork/fork_command.rs new file mode 100644 index 000000000..7e4fdc2b6 --- /dev/null +++ b/crates/tui/src/commands/groups/session/fork/fork_command.rs @@ -0,0 +1,32 @@ +//! Fork command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Fork; +impl Command for Fork { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "fork", + aliases: &["branch"], + usage: "/fork", + description_id: MessageId::CmdForkDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::fork_impl::fork(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Fork.info(); + assert_eq!(info.name, "fork"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/fork/fork_impl.rs b/crates/tui/src/commands/groups/session/fork/fork_impl.rs new file mode 100644 index 000000000..3096070c4 --- /dev/null +++ b/crates/tui/src/commands/groups/session/fork/fork_impl.rs @@ -0,0 +1,118 @@ +use crate::commands::CommandResult; +use crate::session_manager::{ + create_saved_session_with_id_and_mode, create_saved_session_with_mode, +}; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn fork(app: &mut App) -> CommandResult { + if app.api_messages.is_empty() { + return CommandResult::error("Nothing to fork. Send or load a message first."); + } + + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(manager) => manager, + Err(err) => { + return CommandResult::error(format!("could not open sessions directory: {err}")); + } + }; + + let parent_id = app + .current_session_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut parent = create_saved_session_with_id_and_mode( + parent_id, + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + app.sync_cost_to_metadata(&mut parent.metadata); + parent.artifacts = app.session_artifacts.clone(); + + if let Err(err) = manager.save_session(&parent) { + return CommandResult::error(format!("Failed to save parent session: {err}")); + } + + let mut forked = create_saved_session_with_mode( + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + forked.metadata.copy_cost_from(&parent.metadata); + forked.metadata.mark_forked_from(&parent.metadata); + + if let Err(err) = manager.save_session(&forked) { + return CommandResult::error(format!("Failed to save forked session: {err}")); + } + + app.current_session_id = Some(forked.metadata.id.clone()); + let fork_id = forked.metadata.id.clone(); + let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string(); + let fork_label = crate::session_manager::truncate_id(&fork_id).to_string(); + + CommandResult::with_message_and_action( + format!("Forked session {parent_label} -> {fork_label}"), + AppAction::SyncSession { + session_id: Some(fork_id), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use crate::test_support::EnvVarGuard; + use tempfile::TempDir; + + #[test] + fn fork_saves_parent_and_switches_to_child_session() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let previous_home = home_guard.previous(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("parent-session".to_string()); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "try another path".to_string(), + cache_control: None, + }], + }); + + let result = fork(&mut app); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("fork session id"); + assert_ne!(new_id, "parent-session"); + assert!(result.message.as_deref().unwrap_or("").contains("Forked")); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let parent = manager + .load_session("parent-session") + .expect("parent saved"); + let child = manager.load_session(&new_id).expect("child saved"); + assert_eq!(parent.messages.len(), 1); + assert_eq!( + child.metadata.parent_session_id.as_deref(), + Some("parent-session") + ); + assert_eq!(child.metadata.forked_from_message_count, Some(1)); + drop(home_guard); + assert_eq!(std::env::var_os("HOME"), previous_home); + } +} diff --git a/crates/tui/src/commands/groups/session/fork/mod.rs b/crates/tui/src/commands/groups/session/fork/mod.rs new file mode 100644 index 000000000..7a5abc587 --- /dev/null +++ b/crates/tui/src/commands/groups/session/fork/mod.rs @@ -0,0 +1,5 @@ +//! Fork command. + +pub mod fork_command; +pub mod fork_impl; +pub use fork_command::Fork; diff --git a/crates/tui/src/commands/groups/session/load/load_command.rs b/crates/tui/src/commands/groups/session/load/load_command.rs new file mode 100644 index 000000000..80f89143a --- /dev/null +++ b/crates/tui/src/commands/groups/session/load/load_command.rs @@ -0,0 +1,32 @@ +//! Load command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Load; +impl Command for Load { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "load", + aliases: &["jiazai"], + usage: "/load ", + description_id: MessageId::CmdLoadDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::load_impl::load(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Load.info(); + assert_eq!(info.name, "load"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/load/load_impl.rs b/crates/tui/src/commands/groups/session/load/load_impl.rs new file mode 100644 index 000000000..4501f8c56 --- /dev/null +++ b/crates/tui/src/commands/groups/session/load/load_impl.rs @@ -0,0 +1,274 @@ +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; +use crate::tui::history::history_cells_from_message; + +pub(crate) fn load(app: &mut App, path: Option<&str>) -> CommandResult { + let load_path = if let Some(p) = path { + if p.contains('/') || p.contains('\\') { + PathBuf::from(p) + } else { + app.workspace.join(p) + } + } else { + return CommandResult::error("Usage: /load "); + }; + + let content = match std::fs::read_to_string(&load_path) { + Ok(c) => c, + Err(e) => { + return CommandResult::error(format!("Failed to read session file: {e}")); + } + }; + + let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { + Ok(s) => s, + Err(e) => { + return CommandResult::error(format!("Failed to parse session file: {e}")); + } + }; + + app.api_messages.clone_from(&session.messages); + app.clear_history(); + let cells_to_add: Vec<_> = app + .api_messages + .iter() + .flat_map(history_cells_from_message) + .collect(); + app.extend_history(cells_to_add); + app.mark_history_updated(); + app.viewport.transcript_selection.clear(); + app.set_model_selection(session.metadata.model.clone()); + app.update_model_compaction_budget(); + app.workspace.clone_from(&session.metadata.workspace); + app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); + app.session.total_conversation_tokens = app.session.total_tokens; + app.session.reset_token_breakdown(); + app.session.session_cost = 0.0; + app.session.session_cost_cny = 0.0; + app.session.subagent_cost = 0.0; + app.session.subagent_cost_cny = 0.0; + app.session.subagent_cost_event_seqs.clear(); + app.session.displayed_cost_high_water = 0.0; + app.session.displayed_cost_high_water_cny = 0.0; + app.session.last_prompt_tokens = None; + app.session.last_completion_tokens = None; + app.session.last_prompt_cache_hit_tokens = None; + app.session.last_prompt_cache_miss_tokens = None; + app.session.last_reasoning_replay_tokens = None; + app.session.turn_cache_history.clear(); + app.current_session_id = Some(session.metadata.id.clone()); + app.session_artifacts = session.artifacts.clone(); + if let Some(sp) = session.system_prompt { + app.system_prompt = Some(crate::models::SystemPrompt::Text(sp)); + } + app.scroll_to_bottom(); + + CommandResult::with_message_and_action( + format!( + "Session loaded from {} (ID: {}, {} messages)", + load_path.display(), + crate::session_manager::truncate_id(&session.metadata.id), + session.metadata.message_count + ), + AppAction::SyncSession { + session_id: app.current_session_id.clone(), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::save::save_impl::save; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use crate::config::DEFAULT_TEXT_MODEL; + use crate::tui::app::{ReasoningEffort, TurnCacheRecord}; + use std::time::Instant; + use tempfile::TempDir; + + #[test] + fn test_load_without_path_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = load(&mut app, None); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Usage: /load")); + } + + #[test] + fn test_load_nonexistent_file_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = load(&mut app, Some("nonexistent.json")); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Failed to read")); + } + + #[test] + fn test_load_invalid_json_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let bad_file = tmpdir.path().join("bad.json"); + std::fs::write(&bad_file, "not valid json").unwrap(); + let result = load(&mut app, Some(bad_file.to_str().unwrap())); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Failed to parse")); + } + + #[test] + fn test_load_valid_session_restores_state() { + let tmpdir = TempDir::new().unwrap(); + let mut app1 = create_test_app_with_tmpdir(&tmpdir); + app1.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "Hello".to_string(), + cache_control: None, + }], + }); + app1.session.total_tokens = 500; + let save_path = tmpdir.path().join("test.json"); + save(&mut app1, Some(save_path.to_str().unwrap())); + + let mut app2 = create_test_app_with_tmpdir(&tmpdir); + let result = load(&mut app2, Some(save_path.to_str().unwrap())); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Session loaded from")); + assert!(msg.contains("ID:")); + assert!(msg.contains("messages")); + assert_eq!(app2.api_messages.len(), 1); + assert_eq!(app2.session.total_tokens, 500); + assert!(app2.current_session_id.is_some()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + } + + #[test] + fn load_auto_model_session_restores_auto_mode() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app.set_model_selection("auto".to_string()); + saved_app.last_effective_model = Some("deepseek-v4-flash".to_string()); + saved_app.last_effective_reasoning_effort = Some(ReasoningEffort::Low); + let save_path = tmpdir.path().join("auto_model.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.set_model_selection("deepseek-v4-flash".to_string()); + app.reasoning_effort = ReasoningEffort::High; + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert!(app.auto_model); + assert_eq!(app.model, "auto"); + assert_eq!(app.model_selection_for_persistence(), "auto"); + assert_eq!(app.last_effective_model, None); + assert_eq!(app.last_effective_reasoning_effort, None); + assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(app.effective_model_for_budget(), DEFAULT_TEXT_MODEL); + } + + #[test] + fn load_restores_artifact_registry() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app + .session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "checking crate".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + let save_path = tmpdir.path().join("artifact_load.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_stale".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "stale-session".to_string(), + tool_call_id: "stale".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 1, + preview: "stale".to_string(), + storage_path: tmpdir.path().join("stale.txt"), + }); + + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert_eq!(app.session_artifacts, saved_app.session_artifacts); + } + + #[test] + fn load_resets_cache_history_and_cost() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "checkpoint".to_string(), + cache_control: None, + }], + }); + saved_app.session.total_tokens = 500; + let save_path = tmpdir.path().join("checkpoint.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.session.session_cost = 1.25; + app.session.session_cost_cny = 9.13; + app.session.subagent_cost = 0.75; + app.session.subagent_cost_cny = 5.48; + app.session.subagent_cost_event_seqs.insert(42); + app.session.displayed_cost_high_water = 2.0; + app.session.displayed_cost_high_water_cny = 14.61; + app.session.last_prompt_tokens = Some(120); + app.session.last_completion_tokens = Some(35); + app.session.last_prompt_cache_hit_tokens = Some(80); + app.session.last_prompt_cache_miss_tokens = Some(40); + app.session.last_reasoning_replay_tokens = Some(12); + app.push_turn_cache_record(TurnCacheRecord { + input_tokens: 120, + output_tokens: 35, + cache_hit_tokens: Some(80), + cache_miss_tokens: Some(40), + reasoning_replay_tokens: Some(12), + recorded_at: Instant::now(), + }); + + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(result.message.is_some()); + assert_eq!(app.session.total_tokens, 500); + assert_eq!(app.session.total_conversation_tokens, 500); + assert_eq!(app.session.session_cost, 0.0); + assert_eq!(app.session.session_cost_cny, 0.0); + assert_eq!(app.session.subagent_cost, 0.0); + assert_eq!(app.session.subagent_cost_cny, 0.0); + assert!(app.session.subagent_cost_event_seqs.is_empty()); + assert_eq!(app.session.displayed_cost_high_water, 0.0); + assert_eq!(app.session.displayed_cost_high_water_cny, 0.0); + assert_eq!(app.session.last_prompt_tokens, None); + assert_eq!(app.session.last_completion_tokens, None); + assert_eq!(app.session.last_prompt_cache_hit_tokens, None); + assert_eq!(app.session.last_prompt_cache_miss_tokens, None); + assert_eq!(app.session.last_reasoning_replay_tokens, None); + assert!(app.session.turn_cache_history.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/load/mod.rs b/crates/tui/src/commands/groups/session/load/mod.rs new file mode 100644 index 000000000..7839041f7 --- /dev/null +++ b/crates/tui/src/commands/groups/session/load/mod.rs @@ -0,0 +1,5 @@ +//! Load command. + +pub mod load_command; +pub mod load_impl; +pub use load_command::Load; diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs new file mode 100644 index 000000000..cc6966bba --- /dev/null +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -0,0 +1,46 @@ +//! Session commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod compact; +pub(crate) mod export; +pub(crate) mod fork; +pub(crate) mod load; +pub(crate) mod new; +pub(crate) mod purge; +pub(crate) mod rename; +pub(crate) mod save; +pub(crate) mod sessions; +#[cfg(test)] +pub(crate) mod test_support; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::compact::Compact; +use self::export::Export; +use self::fork::Fork; +use self::load::Load; +use self::new::New; +use self::purge::Purge; +use self::rename::Rename; +use self::save::Save; +use self::sessions::Sessions; + +pub struct SessionCommands; +impl CommandGroup for SessionCommands { + fn commands(&self) -> Vec> { + vec![ + Box::new(Rename), + Box::new(Save), + Box::new(Fork), + Box::new(New), + Box::new(Sessions), + Box::new(Load), + Box::new(Compact), + Box::new(Purge), + Box::new(Export), + ] + } +} diff --git a/crates/tui/src/commands/groups/session/new/mod.rs b/crates/tui/src/commands/groups/session/new/mod.rs new file mode 100644 index 000000000..96ac0eb1f --- /dev/null +++ b/crates/tui/src/commands/groups/session/new/mod.rs @@ -0,0 +1,5 @@ +//! New command. + +pub mod new_command; +pub mod new_impl; +pub use new_command::New; diff --git a/crates/tui/src/commands/groups/session/new/new_command.rs b/crates/tui/src/commands/groups/session/new/new_command.rs new file mode 100644 index 000000000..3f2ae778f --- /dev/null +++ b/crates/tui/src/commands/groups/session/new/new_command.rs @@ -0,0 +1,32 @@ +//! New command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct New; +impl Command for New { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "new", + aliases: &[], + usage: "/new", + description_id: MessageId::CmdNewDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::new_impl::new_session(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = New.info(); + assert_eq!(info.name, "new"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/new/new_impl.rs b/crates/tui/src/commands/groups/session/new/new_impl.rs new file mode 100644 index 000000000..ccb6db5d0 --- /dev/null +++ b/crates/tui/src/commands/groups/session/new/new_impl.rs @@ -0,0 +1,180 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { + let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => false, + Some("--force" | "force") => true, + Some(other) => { + return CommandResult::error(format!( + "Usage: /new [--force]\n\nUnknown argument: {other}" + )); + } + }; + + if !force { + let blockers = new_session_blockers(app); + if !blockers.is_empty() { + return CommandResult::error(format!( + "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", + blockers.join(", ") + )); + } + } + + let new_id = uuid::Uuid::new_v4().to_string(); + crate::conversation_state::reset_conversation_state(app); + app.clear_input(); + app.session_artifacts.clear(); + app.session_context_references.clear(); + app.tool_evidence.clear(); + app.current_session_id = Some(new_id.clone()); + app.session_title = Some("New Session".to_string()); + app.scroll_to_bottom(); + + CommandResult::with_message_and_action( + format!( + "Started new session {} (New Session). Previous sessions remain available via /resume.", + crate::session_manager::truncate_id(&new_id) + ), + AppAction::SyncSession { + session_id: Some(new_id), + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +fn new_session_blockers(app: &App) -> Vec<&'static str> { + let mut blockers = Vec::new(); + if !app.input.trim().is_empty() { + blockers.push("the composer has unsent text"); + } + if !app.queued_messages.is_empty() || app.queued_draft.is_some() { + blockers.push("queued messages are pending"); + } + if app.is_loading || app.runtime_turn_status.as_deref() == Some("in_progress") { + blockers.push("a turn is in progress"); + } + if app.is_compacting { + blockers.push("context compaction is running"); + } + if app.task_panel.iter().any(|task| task.status == "running") { + blockers.push("background tasks are running"); + } + blockers +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use crate::tui::history::HistoryCell; + use tempfile::TempDir; + + #[test] + fn new_session_from_resumed_state_creates_distinct_empty_session() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.session_title = Some("Old Session".to_string()); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "continue this thread".to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::System { + content: "old transcript".to_string(), + }); + app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); + app.session.total_tokens = 123; + app.session.session_cost = 1.25; + + let result = new_session(&mut app, None); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("new session id"); + assert_ne!(new_id, "old-session"); + assert_eq!(app.session_title.as_deref(), Some("New Session")); + assert!(app.api_messages.is_empty()); + assert!(app.history.is_empty()); + assert!(app.system_prompt.is_none()); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.session_cost, 0.0); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/resume") + ); + match result.action { + Some(AppAction::SyncSession { + session_id, + messages, + system_prompt, + .. + }) => { + assert_eq!(session_id.as_deref(), Some(new_id.as_str())); + assert!(messages.is_empty()); + assert!(system_prompt.is_none()); + } + other => panic!("expected SyncSession action, got {other:?}"), + } + } + + #[test] + fn new_session_blocks_unsent_input_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.input, "draft text"); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/new --force") + ); + } + + #[test] + fn new_session_force_discards_unsent_input() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, Some("--force")); + + assert!(!result.is_error, "{:?}", result.message); + assert_ne!(app.current_session_id.as_deref(), Some("old-session")); + assert!(app.input.is_empty()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + } + + #[test] + fn new_session_blocks_in_flight_turn_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.is_loading = true; + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert!(result.action.is_none()); + } +} diff --git a/crates/tui/src/commands/groups/session/purge/mod.rs b/crates/tui/src/commands/groups/session/purge/mod.rs new file mode 100644 index 000000000..717acec75 --- /dev/null +++ b/crates/tui/src/commands/groups/session/purge/mod.rs @@ -0,0 +1,5 @@ +//! Purge command. + +pub mod purge_command; +pub mod purge_impl; +pub use purge_command::Purge; diff --git a/crates/tui/src/commands/groups/session/purge/purge_command.rs b/crates/tui/src/commands/groups/session/purge/purge_command.rs new file mode 100644 index 000000000..d95835f05 --- /dev/null +++ b/crates/tui/src/commands/groups/session/purge/purge_command.rs @@ -0,0 +1,32 @@ +//! Purge command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Purge; +impl Command for Purge { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "purge", + aliases: &["qingchu"], + usage: "/purge", + description_id: MessageId::CmdPurgeDescription, + } + } + fn execute(&self, app: &mut App, _args: Option<&str>) -> CommandResult { + super::purge_impl::purge(app) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Purge.info(); + assert_eq!(info.name, "purge"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/purge/purge_impl.rs b/crates/tui/src/commands/groups/session/purge/purge_impl.rs new file mode 100644 index 000000000..997e0daa5 --- /dev/null +++ b/crates/tui/src/commands/groups/session/purge/purge_impl.rs @@ -0,0 +1,27 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn purge(_app: &mut App) -> CommandResult { + CommandResult::with_message_and_action( + "Agent context purge triggered...".to_string(), + AppAction::PurgeContext, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use tempfile::TempDir; + + #[test] + fn purge_triggers_context_purge_action() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = purge(&mut app); + + assert!(result.message.is_some()); + assert!(matches!(result.action, Some(AppAction::PurgeContext))); + } +} diff --git a/crates/tui/src/commands/groups/session/rename/mod.rs b/crates/tui/src/commands/groups/session/rename/mod.rs new file mode 100644 index 000000000..a04c55c64 --- /dev/null +++ b/crates/tui/src/commands/groups/session/rename/mod.rs @@ -0,0 +1,8 @@ +//! Rename command. +//! +//! This module separates the command handler from the implementation. + +pub mod rename_command; +pub mod rename_impl; +pub use rename_command::Rename; +pub use rename_impl::rename; diff --git a/crates/tui/src/commands/groups/session/rename/rename_command.rs b/crates/tui/src/commands/groups/session/rename/rename_command.rs new file mode 100644 index 000000000..766d6beef --- /dev/null +++ b/crates/tui/src/commands/groups/session/rename/rename_command.rs @@ -0,0 +1,33 @@ +//! Rename command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Rename; +impl Command for Rename { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "rename", + aliases: &["gaiming", "chongmingming"], + usage: "/rename ", + description_id: MessageId::CmdRenameDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::session::rename::rename(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Rename.info(); + assert_eq!(info.name, "rename"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/rename.rs b/crates/tui/src/commands/groups/session/rename/rename_impl.rs similarity index 99% rename from crates/tui/src/commands/rename.rs rename to crates/tui/src/commands/groups/session/rename/rename_impl.rs index e551cf61b..c25afa24d 100644 --- a/crates/tui/src/commands/rename.rs +++ b/crates/tui/src/commands/groups/session/rename/rename_impl.rs @@ -3,7 +3,7 @@ use crate::session_manager::{SessionManager, update_session}; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; const MAX_TITLE_LEN: usize = 100; diff --git a/crates/tui/src/commands/groups/session/save/mod.rs b/crates/tui/src/commands/groups/session/save/mod.rs new file mode 100644 index 000000000..cf893fb81 --- /dev/null +++ b/crates/tui/src/commands/groups/session/save/mod.rs @@ -0,0 +1,5 @@ +//! Save command. + +pub mod save_command; +pub mod save_impl; +pub use save_command::Save; diff --git a/crates/tui/src/commands/groups/session/save/save_command.rs b/crates/tui/src/commands/groups/session/save/save_command.rs new file mode 100644 index 000000000..2365c2049 --- /dev/null +++ b/crates/tui/src/commands/groups/session/save/save_command.rs @@ -0,0 +1,32 @@ +//! Save command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Save; +impl Command for Save { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "save", + aliases: &[], + usage: "/save [path]", + description_id: MessageId::CmdSaveDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::save_impl::save(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Save.info(); + assert_eq!(info.name, "save"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/save/save_impl.rs b/crates/tui/src/commands/groups/session/save/save_impl.rs new file mode 100644 index 000000000..a2b67fdf5 --- /dev/null +++ b/crates/tui/src/commands/groups/session/save/save_impl.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::session_manager::create_saved_session_with_mode; +use crate::tui::app::App; + +/// Save session to file. +/// +/// When an explicit path is given, the session is exported there. Without a +/// path, the session is saved into the managed session directory. +pub(crate) fn save(app: &mut App, path: Option<&str>) -> CommandResult { + let save_path = if let Some(p) = path { + PathBuf::from(p) + } else { + let dir = crate::session_manager::default_sessions_dir() + .unwrap_or_else(|_| app.workspace.clone()); + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + dir.join(format!("session_{timestamp}.json")) + }; + + let messages = app.api_messages.clone(); + let mut session = create_saved_session_with_mode( + &messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + app.sync_cost_to_metadata(&mut session.metadata); + session.artifacts = app.session_artifacts.clone(); + + let sessions_dir = save_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf); + + match std::fs::create_dir_all(&sessions_dir) { + Ok(()) => { + let json = match serde_json::to_string_pretty(&session) { + Ok(j) => j, + Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")), + }; + match std::fs::write(&save_path, json) { + Ok(()) => { + app.current_session_id = Some(session.metadata.id.clone()); + CommandResult::message(format!( + "Session saved to {} (ID: {})", + save_path.display(), + crate::session_manager::truncate_id(&session.metadata.id) + )) + } + Err(e) => CommandResult::error(format!("Failed to save session: {e}")), + } + } + Err(e) => CommandResult::error(format!("Failed to create directory: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use crate::test_support::EnvVarGuard; + use tempfile::TempDir; + + #[test] + fn test_save_creates_file_and_sets_session_id() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("test_session.json"); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Session saved to")); + assert!(msg.contains("ID:")); + assert!(app.current_session_id.is_some()); + assert!(save_path.exists()); + } + + #[test] + fn save_preserves_artifact_registry() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("artifact_session.json"); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 512_000, + preview: "cargo test output".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + let saved: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); + assert_eq!(saved.artifacts, app.session_artifacts); + } + + #[test] + fn test_save_with_default_path_uses_managed_sessions_dir() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + let sessions_dir = home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); + let previous_codewhale_home = codewhale_home.previous(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = save(&mut app, None); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let entries: Vec<_> = if sessions_dir.exists() { + std::fs::read_dir(&sessions_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) + .collect() + } else { + Vec::new() + }; + drop(codewhale_home); + assert!( + !entries.is_empty(), + "expected session file in {sessions_dir:?}, got none; msg: {msg}" + ); + assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home); + } + + #[test] + fn test_save_serialization_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("test.json"); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(result.message.is_some()); + } +} diff --git a/crates/tui/src/commands/groups/session/sessions/mod.rs b/crates/tui/src/commands/groups/session/sessions/mod.rs new file mode 100644 index 000000000..a54fbda3e --- /dev/null +++ b/crates/tui/src/commands/groups/session/sessions/mod.rs @@ -0,0 +1,5 @@ +//! Sessions command. + +pub mod sessions_command; +pub mod sessions_impl; +pub use sessions_command::Sessions; diff --git a/crates/tui/src/commands/groups/session/sessions/sessions_command.rs b/crates/tui/src/commands/groups/session/sessions/sessions_command.rs new file mode 100644 index 000000000..487238037 --- /dev/null +++ b/crates/tui/src/commands/groups/session/sessions/sessions_command.rs @@ -0,0 +1,32 @@ +//! Sessions command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Sessions; +impl Command for Sessions { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "sessions", + aliases: &["resume"], + usage: "/sessions", + description_id: MessageId::CmdSessionsDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::sessions_impl::sessions(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Sessions.info(); + assert_eq!(info.name, "sessions"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/session/sessions/sessions_impl.rs b/crates/tui/src/commands/groups/session/sessions/sessions_impl.rs new file mode 100644 index 000000000..f97a0baaa --- /dev/null +++ b/crates/tui/src/commands/groups/session/sessions/sessions_impl.rs @@ -0,0 +1,136 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; +use crate::tui::session_picker::SessionPickerView; + +pub(crate) fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { + let trimmed = arg.unwrap_or("").trim(); + if trimmed.is_empty() { + app.view_stack.push(SessionPickerView::new(&app.workspace)); + return CommandResult::ok(); + } + + let mut parts = trimmed.split_whitespace(); + let action = parts.next().unwrap_or("").to_ascii_lowercase(); + match action.as_str() { + "prune" => prune(app, parts.next()), + "show" | "list" | "picker" => { + app.view_stack.push(SessionPickerView::new(&app.workspace)); + CommandResult::ok() + } + _ => CommandResult::error(format!( + "unknown subcommand `{action}`. usage: /sessions [show|prune <days>]" + )), + } +} + +fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { + let days_str = match days_arg { + Some(s) => s, + None => { + return CommandResult::error( + "usage: /sessions prune <days> (e.g. `/sessions prune 30` to drop sessions older than 30 days)", + ); + } + }; + let days: u64 = match days_str.parse() { + Ok(n) if n > 0 => n, + _ => { + return CommandResult::error(format!( + "expected a positive integer number of days, got `{days_str}`" + )); + } + }; + + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(err) => { + return CommandResult::error(format!("could not open sessions directory: {err}")); + } + }; + + let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60)); + match manager.prune_sessions_older_than(max_age) { + Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")), + Ok(n) => CommandResult::message(format!( + "pruned {n} session{} older than {days}d", + if n == 1 { "" } else { "s" } + )), + Err(err) => CommandResult::error(format!("prune failed: {err}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::session::test_support::create_test_app_with_tmpdir; + use tempfile::TempDir; + + #[test] + fn test_sessions_pushes_picker_view() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let initial_kind = app.view_stack.top_kind(); + + let result = sessions(&mut app, None); + + assert_eq!(result.message, None); + assert!(result.action.is_none()); + assert_ne!(app.view_stack.top_kind(), initial_kind); + } + + #[test] + fn test_sessions_show_subcommand_pushes_picker_view() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let initial_kind = app.view_stack.top_kind(); + + let result = sessions(&mut app, Some("show")); + + assert_eq!(result.message, None); + assert_ne!(app.view_stack.top_kind(), initial_kind); + } + + #[test] + fn test_sessions_prune_requires_days_argument() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = sessions(&mut app, Some("prune")); + + assert!(result.is_error); + assert!( + result.message.as_deref().unwrap_or("").contains("usage"), + "expected usage hint: {:?}", + result.message + ); + } + + #[test] + fn test_sessions_prune_rejects_non_positive_days() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + for bad in ["0", "-3", "abc", "3.14"] { + let result = sessions(&mut app, Some(&format!("prune {bad}"))); + assert!(result.is_error, "expected error for `{bad}`"); + } + } + + #[test] + fn test_sessions_unknown_subcommand_errors() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = sessions(&mut app, Some("teleport")); + + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or("") + .contains("unknown subcommand"), + "expected unknown-subcommand error: {:?}", + result.message + ); + } +} diff --git a/crates/tui/src/commands/groups/session/test_support.rs b/crates/tui/src/commands/groups/session/test_support.rs new file mode 100644 index 000000000..d8f194f6d --- /dev/null +++ b/crates/tui/src/commands/groups/session/test_support.rs @@ -0,0 +1,29 @@ +use tempfile::TempDir; + +use crate::config::Config; +use crate::tui::app::{App, TuiOptions}; + +pub(crate) fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + 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: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) +} diff --git a/crates/tui/src/commands/groups/skills/mod.rs b/crates/tui/src/commands/groups/skills/mod.rs new file mode 100644 index 000000000..bdb60b61b --- /dev/null +++ b/crates/tui/src/commands/groups/skills/mod.rs @@ -0,0 +1,34 @@ +//! Skills commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod restore; +pub(crate) mod review; +pub(crate) mod skill; +// The `/skills` command intentionally has the same name as the skills group. +#[allow(clippy::module_inception)] +pub(crate) mod skills; +pub(crate) mod support; +#[cfg(test)] +pub(crate) mod test_support; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::restore::Restore; +use self::review::Review; +use self::skill::Skill; +use self::skills::Skills; + +pub struct SkillsCommands; +impl CommandGroup for SkillsCommands { + fn commands(&self) -> Vec<Box<dyn Command>> { + vec![ + Box::new(Skills), + Box::new(Skill), + Box::new(Review), + Box::new(Restore), + ] + } +} diff --git a/crates/tui/src/commands/groups/skills/restore/mod.rs b/crates/tui/src/commands/groups/skills/restore/mod.rs new file mode 100644 index 000000000..65567fbad --- /dev/null +++ b/crates/tui/src/commands/groups/skills/restore/mod.rs @@ -0,0 +1,8 @@ +//! Restore command. +//! +//! This module separates the command handler from the implementation. + +pub mod restore_command; +pub mod restore_impl; +pub use restore_command::Restore; +pub use restore_impl::restore; diff --git a/crates/tui/src/commands/groups/skills/restore/restore_command.rs b/crates/tui/src/commands/groups/skills/restore/restore_command.rs new file mode 100644 index 000000000..e7085d0e3 --- /dev/null +++ b/crates/tui/src/commands/groups/skills/restore/restore_command.rs @@ -0,0 +1,33 @@ +//! Restore command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Restore; +impl Command for Restore { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "restore", + aliases: &[], + usage: "/restore [N]", + description_id: MessageId::CmdRestoreDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::skills::restore::restore(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Restore.info(); + assert_eq!(info.name, "restore"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/restore.rs b/crates/tui/src/commands/groups/skills/restore/restore_impl.rs similarity index 99% rename from crates/tui/src/commands/restore.rs rename to crates/tui/src/commands/groups/skills/restore/restore_impl.rs index 8ea3540e5..cc3f7c84f 100644 --- a/crates/tui/src/commands/restore.rs +++ b/crates/tui/src/commands/groups/skills/restore/restore_impl.rs @@ -7,7 +7,7 @@ //! (`/trust on` or YOLO) — the user can always view the list, just not //! one-shot revert without a safety net. -use super::CommandResult; +use crate::commands::CommandResult; use crate::snapshot::SnapshotRepo; use crate::tui::app::App; diff --git a/crates/tui/src/commands/groups/skills/review/mod.rs b/crates/tui/src/commands/groups/skills/review/mod.rs new file mode 100644 index 000000000..6e0be204c --- /dev/null +++ b/crates/tui/src/commands/groups/skills/review/mod.rs @@ -0,0 +1,8 @@ +//! Review command. +//! +//! This module separates the command handler from the implementation. + +pub mod review_command; +pub mod review_impl; +pub use review_command::Review; +pub use review_impl::review; diff --git a/crates/tui/src/commands/groups/skills/review/review_command.rs b/crates/tui/src/commands/groups/skills/review/review_command.rs new file mode 100644 index 000000000..4c15983a8 --- /dev/null +++ b/crates/tui/src/commands/groups/skills/review/review_command.rs @@ -0,0 +1,33 @@ +//! Review command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Review; +impl Command for Review { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "review", + aliases: &["shencha"], + usage: "/review <target>", + description_id: MessageId::CmdReviewDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::skills::review::review(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Review.info(); + assert_eq!(info.name, "review"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/review.rs b/crates/tui/src/commands/groups/skills/review/review_impl.rs similarity index 99% rename from crates/tui/src/commands/review.rs rename to crates/tui/src/commands/groups/skills/review/review_impl.rs index 518d0ff59..c3d4fe677 100644 --- a/crates/tui/src/commands/review.rs +++ b/crates/tui/src/commands/groups/skills/review/review_impl.rs @@ -4,7 +4,7 @@ use crate::skills::{SkillRegistry, default_skills_dir}; use crate::tui::app::{App, AppAction}; use crate::tui::history::HistoryCell; -use super::CommandResult; +use crate::commands::CommandResult; fn warnings_suffix(registry: &SkillRegistry) -> String { if registry.warnings().is_empty() { diff --git a/crates/tui/src/commands/groups/skills/skill/mod.rs b/crates/tui/src/commands/groups/skills/skill/mod.rs new file mode 100644 index 000000000..ef13809fc --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skill/mod.rs @@ -0,0 +1,5 @@ +//! Skill command. + +pub mod skill_command; +pub mod skill_impl; +pub use skill_command::Skill; diff --git a/crates/tui/src/commands/groups/skills/skill/skill_command.rs b/crates/tui/src/commands/groups/skills/skill/skill_command.rs new file mode 100644 index 000000000..9dd89a7c1 --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skill/skill_command.rs @@ -0,0 +1,32 @@ +//! Skill command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Skill; +impl Command for Skill { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "skill", + aliases: &["jineng"], + usage: "/skill <name|install|update|uninstall|trust>", + description_id: MessageId::CmdSkillDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::skill_impl::run_skill(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Skill.info(); + assert_eq!(info.name, "skill"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/skills/skill/skill_impl.rs b/crates/tui/src/commands/groups/skills/skill/skill_impl.rs new file mode 100644 index 000000000..989312c5b --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skill/skill_impl.rs @@ -0,0 +1,283 @@ +use crate::commands::CommandResult; +use crate::commands::groups::skills::support::{ + discover_visible_skills, installer_settings, needs_approval_message, network_denied_message, + path_or_default, render_skill_warnings, run_async, +}; +use crate::skills::install::{self, InstallOutcome, InstallSource, UpdateResult}; +use crate::tui::app::App; +use crate::tui::history::HistoryCell; + +pub(crate) fn run_skill(app: &mut App, args: Option<&str>) -> CommandResult { + let raw = match args { + Some(n) => n.trim(), + None => { + return CommandResult::error( + "Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://...|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>", + ); + } + }; + + let mut iter = raw.splitn(2, char::is_whitespace); + let head = iter.next().unwrap_or("").trim(); + let rest = iter.next().unwrap_or("").trim(); + match head { + "install" => return install_skill(app, rest), + "update" => return update_skill(app, rest), + "uninstall" => return uninstall_skill(app, rest), + "trust" => return trust_skill(app, rest), + _ => {} + } + + activate_skill(app, raw) +} + +/// Try to run a skill by exact slash-command name. +/// +/// This is used by the command dispatcher after static command lookup misses. +pub(crate) fn run_skill_by_name( + app: &mut App, + name: &str, + _arg: Option<&str>, +) -> Option<CommandResult> { + let registry = discover_visible_skills(app); + if registry.get(name).is_some() { + Some(activate_skill(app, name)) + } else { + None + } +} + +fn activate_skill(app: &mut App, name: &str) -> CommandResult { + let name = if name == "new" { "skill-creator" } else { name }; + let registry = discover_visible_skills(app); + + if let Some(skill) = registry.get(name) { + let instruction = format!( + "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.", + skill.name, skill.body + ); + + app.add_message(HistoryCell::System { + content: format!("Activated skill: {}\n\n{}", skill.name, skill.description), + }); + app.active_skill = Some(instruction); + + CommandResult::message(format!( + "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.", + skill.name, skill.description + )) + } else { + let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect(); + let warnings = render_skill_warnings(®istry); + + if available.is_empty() { + CommandResult::error(format!( + "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}" + )) + } else { + CommandResult::error(format!( + "Skill '{}' not found.\n\nAvailable skills: {}{}", + name, + available.join(", "), + warnings + )) + } + } +} + +fn install_skill(app: &mut App, spec: &str) -> CommandResult { + if spec.is_empty() { + return CommandResult::error( + "Usage: /skill install <github:owner/repo|https://...|<registry-name>>", + ); + } + let source = match InstallSource::parse(spec) { + Ok(s) => s, + Err(err) => return CommandResult::error(format!("Invalid install source: {err}")), + }; + let skills_dir = app.skills_dir.clone(); + let (network, max_size, registry_url) = installer_settings(app); + + let outcome = run_async(async move { + install::install_with_registry( + source, + &skills_dir, + max_size, + &network, + false, + ®istry_url, + ) + .await + }); + + match outcome { + Ok(InstallOutcome::Installed(installed)) => { + app.refresh_skill_cache(); + let path_str = path_or_default(&installed.path); + CommandResult::message(format!( + "Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.", + installed.name, spec, path_str + )) + } + Ok(InstallOutcome::NeedsApproval(host)) => { + CommandResult::error(needs_approval_message(&host)) + } + Ok(InstallOutcome::NetworkDenied(host)) => { + CommandResult::error(network_denied_message(&host)) + } + Err(err) => CommandResult::error(format!("Install failed: {err:#}")), + } +} + +fn update_skill(app: &mut App, name: &str) -> CommandResult { + if name.is_empty() { + return CommandResult::error("Usage: /skill update <name>"); + } + let skills_dir = app.skills_dir.clone(); + let (network, max_size, registry_url) = installer_settings(app); + let owned_name = name.to_string(); + let outcome = run_async(async move { + install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url) + .await + }); + + match outcome { + Ok(UpdateResult::NoChange) => { + CommandResult::message(format!("Skill '{name}': no upstream change.")) + } + Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!( + "Skill '{}' updated. Location: {}", + installed.name, + path_or_default(&installed.path) + )), + Ok(UpdateResult::NeedsApproval(host)) => { + CommandResult::error(needs_approval_message(&host)) + } + Ok(UpdateResult::NetworkDenied(host)) => { + CommandResult::error(network_denied_message(&host)) + } + Err(err) => CommandResult::error(format!("Update failed: {err:#}")), + } +} + +fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { + if name.is_empty() { + return CommandResult::error("Usage: /skill uninstall <name>"); + } + match install::uninstall(name, &app.skills_dir) { + Ok(()) => { + app.refresh_skill_cache(); + CommandResult::message(format!("Removed skill '{name}'.")) + } + Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")), + } +} + +fn trust_skill(app: &mut App, name: &str) -> CommandResult { + if name.is_empty() { + return CommandResult::error("Usage: /skill trust <name>"); + } + match install::trust(name, &app.skills_dir) { + Ok(()) => CommandResult::message(format!( + "Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/." + )), + Err(err) => CommandResult::error(format!("Trust failed: {err:#}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::skills::test_support::{ + IsolatedHome, create_skill_dir, create_test_app_with_tmpdir, + }; + use tempfile::TempDir; + + #[test] + fn test_skill_subcommand_dispatch_install_usage() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill(&mut app, Some("install")); + + let msg = result.message.unwrap(); + assert!(msg.contains("/skill install"), "got: {msg}"); + } + + #[test] + fn test_skill_subcommand_dispatch_uninstall_missing() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill(&mut app, Some("uninstall absent-skill")); + + let msg = result.message.unwrap(); + assert!(msg.contains("not installed"), "got: {msg}"); + } + + #[test] + fn test_run_skill_without_name() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill(&mut app, None); + + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Usage: /skill")); + } + + #[test] + fn test_run_skill_not_found() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill(&mut app, Some("nonexistent")); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("not found")); + } + + #[test] + fn test_run_skill_activates() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "test-skill", + "---\nname: test-skill\ndescription: A test skill\n---\nDo something special", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill(&mut app, Some("test-skill")); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Skill 'test-skill' activated")); + assert!(msg.contains("A test skill")); + assert!(app.active_skill.is_some()); + assert!(!app.history.is_empty()); + } + + #[test] + fn run_skill_by_name_activates_existing_skill() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "direct-skill", + "---\nname: direct-skill\ndescription: Direct skill\n---\nDo direct work", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = run_skill_by_name(&mut app, "direct-skill", None); + + assert!(result.is_some()); + assert!(app.active_skill.is_some()); + assert!(!app.history.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/skills/skills/mod.rs b/crates/tui/src/commands/groups/skills/skills/mod.rs new file mode 100644 index 000000000..e9b6b3b8b --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skills/mod.rs @@ -0,0 +1,5 @@ +//! Skills command. + +pub mod skills_command; +pub mod skills_impl; +pub use skills_command::Skills; diff --git a/crates/tui/src/commands/groups/skills/skills/skills_command.rs b/crates/tui/src/commands/groups/skills/skills/skills_command.rs new file mode 100644 index 000000000..2e46a6531 --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skills/skills_command.rs @@ -0,0 +1,32 @@ +//! Skills command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Skills; +impl Command for Skills { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "skills", + aliases: &["jinengliebiao"], + usage: "/skills [--remote|sync|<prefix>]", + description_id: MessageId::CmdSkillsDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + super::skills_impl::list_skills(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Skills.info(); + assert_eq!(info.name, "skills"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/skills/skills/skills_impl.rs b/crates/tui/src/commands/groups/skills/skills/skills_impl.rs new file mode 100644 index 000000000..b23968d45 --- /dev/null +++ b/crates/tui/src/commands/groups/skills/skills/skills_impl.rs @@ -0,0 +1,430 @@ +use std::fmt::Write; + +use crate::commands::CommandResult; +use crate::commands::groups::skills::support::{ + discover_visible_skills, format_registry_error, installer_settings, needs_approval_message, + network_denied_message, render_skill_warnings, run_async, +}; +use crate::skills::install::{self, RegistryFetchResult, SkillSyncOutcome, SyncResult}; +use crate::tui::app::App; + +/// List all available skills. Pass `--remote` or `remote` to fetch the +/// curated registry. Pass `sync` to pull the registry index and download all +/// skills to the local cache. +pub(crate) fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { + let mut prefix: Option<String> = None; + if let Some(arg) = arg { + let trimmed = arg.trim(); + if trimmed == "--remote" || trimmed == "remote" { + return list_remote_skills(app); + } + if trimmed == "sync" || trimmed == "--sync" { + return sync_skills(app); + } + if !trimmed.is_empty() { + if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 { + return CommandResult::error("Usage: /skills [--remote|sync|<name-prefix>]"); + } + prefix = Some(trimmed.to_ascii_lowercase()); + } + } + + let skills_dir = app.skills_dir.clone(); + let registry = discover_visible_skills(app); + let warnings = render_skill_warnings(®istry); + + if registry.is_empty() { + let msg = format!( + "No skills found.\n\n\ + Skills location: {}\n\n\ + To add skills, create directories with SKILL.md files:\n \ + {}/my-skill/SKILL.md\n\n\ + Format:\n \ + ---\n \ + name: my-skill\n \ + description: What this skill does\n \ + allowed-tools: read_file, list_dir\n \ + ---\n\n \ + <instructions here>{warnings}", + skills_dir.display(), + skills_dir.display() + ); + return CommandResult::message(msg); + } + + let filtered: Vec<&crate::skills::Skill> = if let Some(p) = prefix.as_deref() { + registry + .list() + .iter() + .filter(|s| s.name.to_ascii_lowercase().starts_with(p)) + .collect() + } else { + registry.list().iter().collect() + }; + + if filtered.is_empty() { + let p = prefix.as_deref().unwrap_or(""); + return CommandResult::message(format!( + "No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}", + registry.len() + )); + } + + let mut output = if let Some(p) = prefix.as_deref() { + format!( + "Available skills matching `{p}` ({} of {}):\n", + filtered.len(), + registry.len() + ) + } else { + format!("Available skills ({}):\n", registry.len()) + }; + output.push_str("-----------------------------\n"); + + if prefix.is_some() { + for (idx, skill) in filtered.iter().enumerate() { + if idx > 0 { + output.push('\n'); + } + let _ = writeln!(output, " /{} - {}", skill.name, skill.description); + } + } else { + let (user_skills, bundled_skills): ( + Vec<&&crate::skills::Skill>, + Vec<&&crate::skills::Skill>, + ) = filtered + .iter() + .partition(|s| !crate::skills::is_bundled_skill_name(&s.name)); + + if !user_skills.is_empty() { + let _ = writeln!(output, "Your skills ({}):", user_skills.len()); + for skill in &user_skills { + let _ = writeln!(output, " /{} - {}", skill.name, skill.description); + } + if !bundled_skills.is_empty() { + output.push('\n'); + } + } + + if !bundled_skills.is_empty() { + let _ = writeln!(output, "Built-in skills ({}):", bundled_skills.len()); + if user_skills.is_empty() { + for skill in &bundled_skills { + let _ = writeln!(output, " /{} - {}", skill.name, skill.description); + } + } else { + let names: Vec<String> = bundled_skills + .iter() + .map(|s| format!("/{}", s.name)) + .collect(); + output.push_str(" "); + output.push_str(&names.join(", ")); + output.push('\n'); + output.push_str(" (run /skills <name> for details on a built-in)\n"); + } + } + } + + let _ = write!( + output, + "\nUse /skill <name> to run a skill\nSkills location: {}{}", + skills_dir.display(), + warnings + ); + + CommandResult::message(output) +} + +fn list_remote_skills(app: &mut App) -> CommandResult { + let (network, _max_size, registry_url) = installer_settings(app); + let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await }); + match registry { + Ok(RegistryFetchResult::Loaded(doc)) => { + if doc.skills.is_empty() { + return CommandResult::message("Registry is empty."); + } + let mut out = format!("Available remote skills ({}):\n", doc.skills.len()); + out.push_str("-----------------------------\n"); + for (name, entry) in &doc.skills { + let _ = writeln!( + out, + " {name} - {} (source: {})", + entry.description.clone().unwrap_or_default(), + entry.source + ); + } + let _ = write!(out, "\nInstall with: /skill install <name>"); + CommandResult::message(out) + } + Ok(RegistryFetchResult::NeedsApproval(host)) => { + CommandResult::error(needs_approval_message(&host)) + } + Ok(RegistryFetchResult::Denied(host)) => { + CommandResult::error(network_denied_message(&host)) + } + Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)), + } +} + +fn sync_skills(app: &mut App) -> CommandResult { + let (network, max_size, registry_url) = installer_settings(app); + let cache_dir = install::default_cache_skills_dir(); + + let result = run_async(async move { + install::sync_registry(&network, ®istry_url, &cache_dir, max_size).await + }); + + match result { + Ok(SyncResult::RegistryDenied(host)) => CommandResult::error(network_denied_message(&host)), + Ok(SyncResult::RegistryNeedsApproval(host)) => { + CommandResult::error(needs_approval_message(&host)) + } + Ok(SyncResult::Done { outcomes }) => { + let total = outcomes.len(); + let mut downloaded = 0usize; + let mut fresh = 0usize; + let mut failed = 0usize; + let mut out = String::from("Registry sync complete.\n\n"); + + for outcome in &outcomes { + match outcome { + SkillSyncOutcome::Downloaded { name, path } => { + downloaded += 1; + let _ = writeln!(out, " [+] {name} - downloaded to {}", path.display()); + } + SkillSyncOutcome::Fresh { name } => { + fresh += 1; + let _ = writeln!(out, " [=] {name} - already up to date"); + } + SkillSyncOutcome::Failed { name, reason } => { + failed += 1; + let _ = writeln!(out, " [!] {name} - failed: {reason}"); + } + SkillSyncOutcome::Denied { name, host } => { + failed += 1; + let _ = writeln!(out, " [!] {name} - network denied ({host})"); + } + SkillSyncOutcome::NeedsApproval { name, host } => { + failed += 1; + let _ = writeln!( + out, + " [?] {name} - needs approval for {host} (run `/network allow {host}` then retry)" + ); + } + } + } + + let _ = write!( + out, + "\n{total} skill(s) processed: {downloaded} downloaded, {fresh} up-to-date, {failed} failed." + ); + + CommandResult::message(out) + } + Err(err) => CommandResult::error(format_registry_error("Sync failed", &err)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::skills::test_support::{ + IsolatedHome, create_skill_dir, create_test_app_with_tmpdir, + }; + use tempfile::TempDir; + + #[cfg_attr( + target_os = "windows", + ignore = "dirs crate uses Win32 API, cannot override" + )] + #[test] + fn test_list_skills_empty_directory() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = list_skills(&mut app, None); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("No skills found")); + assert!(msg.contains("Skills location:")); + } + + #[test] + fn test_list_skills_with_skills() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "test-skill", + "---\nname: test-skill\ndescription: A test skill\n---\nDo something", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = list_skills(&mut app, None); + + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Available skills")); + assert!(msg.contains("/test-skill")); + } + + #[cfg_attr( + target_os = "windows", + ignore = "dirs crate uses Win32 API, cannot override" + )] + #[test] + fn test_list_skills_filters_by_name_prefix() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + create_skill_dir( + &tmpdir, + "alphabet-helper", + "---\nname: alphabet-helper\ndescription: Helper\n---\nbody", + ); + create_skill_dir( + &tmpdir, + "beta-skill", + "---\nname: beta-skill\ndescription: Second\n---\nbody", + ); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, Some("alph")); + let msg = result.message.expect("filter result has message"); + + assert!(msg.contains("/alpha-skill")); + assert!(msg.contains("/alphabet-helper")); + assert!( + !msg.contains("/beta-skill"), + "beta-skill must be filtered out" + ); + assert!( + msg.contains("matching `alph`") && msg.contains("2 of 3"), + "header should show count + total, got: {msg}" + ); + } + + #[test] + fn test_list_skills_filter_is_case_insensitive() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = list_skills(&mut app, Some("ALPH")); + + let msg = result.message.expect("case-insensitive filter has message"); + assert!(msg.contains("/alpha-skill")); + } + + #[test] + fn test_list_skills_filter_with_zero_matches_says_so() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First\n---\nbody", + ); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = list_skills(&mut app, Some("nonexistent")); + + let msg = result.message.expect("zero-match filter still has message"); + assert!(msg.contains("No skills match prefix `nonexistent`")); + assert!(msg.contains("Run /skills")); + } + + #[test] + fn test_list_skills_rejects_flag_like_prefix() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = list_skills(&mut app, Some("--bogus")); + + assert!( + result.is_error, + "expected usage error for --bogus, got: {result:?}" + ); + assert!( + result + .message + .as_deref() + .is_some_and(|m| m.contains("name-prefix")), + "expected --bogus error message to mention name-prefix, got: {result:?}" + ); + } + + #[test] + fn test_list_skills_renders_user_skills_under_your_skills_section() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + create_skill_dir( + &tmpdir, + "alpha-skill", + "---\nname: alpha-skill\ndescription: First skill\n---\nDo alpha work", + ); + create_skill_dir( + &tmpdir, + "beta-skill", + "---\nname: beta-skill\ndescription: Second skill\n---\nDo beta work", + ); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, None); + let msg = result.message.unwrap(); + + let section = msg + .find("Your skills") + .expect("user skills section header missing"); + let alpha = msg.find("/alpha-skill").expect("alpha skill should render"); + let beta = msg.find("/beta-skill").expect("beta skill should render"); + assert!( + alpha > section, + "alpha-skill should follow the header: {msg}" + ); + assert!(beta > section, "beta-skill should follow the header: {msg}"); + assert!(msg.contains("/alpha-skill - First skill"), "got: {msg}"); + assert!(msg.contains("/beta-skill - Second skill"), "got: {msg}"); + } + + #[test] + fn test_list_skills_merges_workspace_and_configured_dirs() { + let tmpdir = TempDir::new().unwrap(); + let _home = IsolatedHome::new(&tmpdir); + let workspace_skill_dir = tmpdir + .path() + .join(".agents") + .join("skills") + .join("workspace-skill"); + std::fs::create_dir_all(&workspace_skill_dir).unwrap(); + std::fs::write( + workspace_skill_dir.join("SKILL.md"), + "---\nname: workspace-skill\ndescription: Workspace skill\n---\nDo workspace work", + ) + .unwrap(); + create_skill_dir( + &tmpdir, + "configured-skill", + "---\nname: configured-skill\ndescription: Configured skill\n---\nDo configured work", + ); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = list_skills(&mut app, None); + let msg = result.message.unwrap(); + + assert!(msg.contains("/workspace-skill"), "got: {msg}"); + assert!(msg.contains("/configured-skill"), "got: {msg}"); + } +} diff --git a/crates/tui/src/commands/groups/skills/support.rs b/crates/tui/src/commands/groups/skills/support.rs new file mode 100644 index 000000000..4defbabcd --- /dev/null +++ b/crates/tui/src/commands/groups/skills/support.rs @@ -0,0 +1,200 @@ +use std::fmt::Write; + +use crate::network_policy::NetworkPolicy; +use crate::skills::SkillRegistry; +use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL}; +use crate::tui::app::App; + +pub(crate) fn discover_visible_skills(app: &App) -> SkillRegistry { + crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) +} + +pub(crate) fn render_skill_warnings(registry: &SkillRegistry) -> String { + if registry.warnings().is_empty() { + return String::new(); + } + + let mut out = String::new(); + let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len()); + for warning in registry.warnings() { + let _ = writeln!(out, " - {warning}"); + } + out +} + +/// Read the active config knobs for skill install/update/sync operations. +/// +/// The TUI app does not carry a `Config` field, and the TOML load is cheap +/// compared with the network operation that follows. +pub(crate) fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) { + let cfg = crate::config::Config::load(None, None).unwrap_or_default(); + let network = cfg + .network + .clone() + .map(|policy| policy.into_runtime()) + .unwrap_or_default(); + let skills_cfg = cfg.skills.as_ref(); + let max_size = skills_cfg + .and_then(|s| s.max_install_size_bytes) + .unwrap_or(DEFAULT_MAX_SIZE_BYTES); + let registry_url = skills_cfg + .and_then(|s| s.registry_url.clone()) + .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); + (network, max_size, registry_url) +} + +pub(crate) fn run_async<F, T>(future: F) -> T +where + F: std::future::Future<Output = T>, +{ + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) +} + +pub(crate) fn path_or_default(path: &std::path::Path) -> String { + path.file_name() + .map(|name| { + let parent = path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + if parent.is_empty() { + name.to_string_lossy().to_string() + } else { + format!("{parent}/{}", name.to_string_lossy()) + } + }) + .unwrap_or_else(|| path.display().to_string()) +} + +pub(crate) fn needs_approval_message(host: &str) -> String { + format!( + "Network policy requires approval for {host}.\n\ + Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry." + ) +} + +pub(crate) fn network_denied_message(host: &str) -> String { + format!( + "Network policy denied access to {host}.\n\ + Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator." + ) +} + +fn registry_fetch_error_hint(err: &anyhow::Error) -> Option<&'static str> { + let msg = format!("{err:#}").to_lowercase(); + if msg.contains("dns") + || msg.contains("name resolution") + || msg.contains("getaddrinfo") + || msg.contains("nodename nor servname") + { + Some( + "Hint: DNS lookup failed. Check internet/DNS connectivity, or override the registry URL in [skills] of ~/.codewhale/config.toml.", + ) + } else if msg.contains("connection refused") + || msg.contains("connection reset") + || msg.contains("connection aborted") + { + Some( + "Hint: connection refused/reset. The registry host may be unreachable from this network (corporate proxy, firewall, offline).", + ) + } else if msg.contains("tls") + || msg.contains("certificate") + || msg.contains("ssl") + || msg.contains("handshake") + { + Some( + "Hint: TLS handshake failed. The system trust store may be missing the registry's CA, or a TLS-intercepting proxy is rewriting the certificate.", + ) + } else if msg.contains(" 404") || msg.contains("not found") { + Some( + "Hint: registry URL returned 404. Verify the registry URL in [skills] of ~/.codewhale/config.toml.", + ) + } else if msg.contains(" 401") || msg.contains(" 403") || msg.contains("forbidden") { + Some( + "Hint: registry returned an auth error. The registry may require credentials or have been moved.", + ) + } else if msg.contains(" 429") || msg.contains("rate limit") || msg.contains("too many") { + Some("Hint: rate-limited by the registry. Try again in a moment.") + } else if msg.contains("timed out") || msg.contains("timeout") { + Some("Hint: request timed out. Network may be slow or the registry host may be down.") + } else { + None + } +} + +pub(crate) fn format_registry_error(prefix: &str, err: &anyhow::Error) -> String { + let mut out = format!("{prefix}: {err:#}"); + if let Some(hint) = registry_fetch_error_hint(err) { + out.push_str("\n\n"); + out.push_str(hint); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_fetch_error_hint_recognises_dns_failures() { + let err = anyhow::Error::msg("error sending request: dns error: failed to lookup") + .context("failed to fetch registry https://example.com/registry.json"); + let hint = registry_fetch_error_hint(&err).expect("dns hint"); + assert!(hint.contains("DNS"), "got: {hint}"); + } + + #[test] + fn registry_fetch_error_hint_recognises_connection_refused() { + let err = anyhow::Error::msg("error sending request: tcp connect: connection refused"); + let hint = registry_fetch_error_hint(&err).expect("refused hint"); + assert!(hint.contains("refused"), "got: {hint}"); + } + + #[test] + fn registry_fetch_error_hint_recognises_tls_failures() { + let err = anyhow::Error::msg("invalid peer certificate: UnknownIssuer (TLS handshake)"); + let hint = registry_fetch_error_hint(&err).expect("tls hint"); + assert!(hint.contains("TLS"), "got: {hint}"); + } + + #[test] + fn registry_fetch_error_hint_recognises_http_status_codes() { + let err_404 = anyhow::Error::msg("registry returned an error status: 404 Not Found"); + assert!( + registry_fetch_error_hint(&err_404) + .map(|h| h.contains("404")) + .unwrap_or(false) + ); + let err_429 = + anyhow::Error::msg("registry returned an error status: 429 Too Many Requests"); + assert!( + registry_fetch_error_hint(&err_429) + .map(|h| h.contains("rate")) + .unwrap_or(false) + ); + } + + #[test] + fn registry_fetch_error_hint_returns_none_for_unrecognised_errors() { + let err = anyhow::Error::msg("a totally novel error nobody anticipated"); + assert!(registry_fetch_error_hint(&err).is_none()); + } + + #[test] + fn format_registry_error_appends_hint_when_pattern_matches() { + let err = anyhow::Error::msg("dns error: nodename nor servname provided"); + let formatted = format_registry_error("Failed to fetch registry", &err); + assert!(formatted.starts_with("Failed to fetch registry: ")); + assert!( + formatted.contains("Hint: DNS"), + "expected hint, got: {formatted}" + ); + } + + #[test] + fn format_registry_error_omits_hint_when_no_pattern_matches() { + let err = anyhow::Error::msg("inscrutable opaque failure"); + let formatted = format_registry_error("Sync failed", &err); + assert_eq!(formatted, "Sync failed: inscrutable opaque failure"); + } +} diff --git a/crates/tui/src/commands/groups/skills/test_support.rs b/crates/tui/src/commands/groups/skills/test_support.rs new file mode 100644 index 000000000..9bab47dbe --- /dev/null +++ b/crates/tui/src/commands/groups/skills/test_support.rs @@ -0,0 +1,94 @@ +use std::ffi::OsString; + +use tempfile::TempDir; + +use crate::config::Config; +use crate::tui::app::{App, TuiOptions}; + +pub(crate) struct IsolatedHome { + _lock: std::sync::MutexGuard<'static, ()>, + home_prev: Option<OsString>, + userprofile_prev: Option<OsString>, + homedrive_prev: Option<OsString>, + homepath_prev: Option<OsString>, +} + +impl IsolatedHome { + pub(crate) fn new(tmpdir: &TempDir) -> Self { + let lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_prev = std::env::var_os("HOME"); + let userprofile_prev = std::env::var_os("USERPROFILE"); + let homedrive_prev = std::env::var_os("HOMEDRIVE"); + let homepath_prev = std::env::var_os("HOMEPATH"); + // SAFETY: tests that mutate process env hold the shared test env + // mutex for the full lifetime of this guard. + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("USERPROFILE", &home); + std::env::set_var("HOMEDRIVE", home.parent().unwrap_or(&home)); + std::env::set_var("HOMEPATH", home.file_name().unwrap_or_default()); + } + Self { + _lock: lock, + home_prev, + userprofile_prev, + homedrive_prev, + homepath_prev, + } + } + + unsafe fn restore_var(key: &str, value: Option<OsString>) { + if let Some(value) = value { + unsafe { std::env::set_var(key, value) }; + } else { + unsafe { std::env::remove_var(key) }; + } + } +} + +impl Drop for IsolatedHome { + fn drop(&mut self) { + // SAFETY: the shared test env mutex is still held while Drop runs. + unsafe { + Self::restore_var("HOME", self.home_prev.take()); + Self::restore_var("USERPROFILE", self.userprofile_prev.take()); + Self::restore_var("HOMEDRIVE", self.homedrive_prev.take()); + Self::restore_var("HOMEPATH", self.homepath_prev.take()); + } + } +} + +pub(crate) fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + 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: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut app = App::new(options, &Config::default()); + app.skills_dir = tmpdir.path().join("skills"); + app +} + +pub(crate) fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) { + let skill_dir = tmpdir.path().join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap(); +} diff --git a/crates/tui/src/commands/groups/test_support.rs b/crates/tui/src/commands/groups/test_support.rs new file mode 100644 index 000000000..c78afa564 --- /dev/null +++ b/crates/tui/src/commands/groups/test_support.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; + +use crate::config::Config; +use crate::tui::app::{App, TuiOptions}; + +pub(crate) fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) +} diff --git a/crates/tui/src/commands/groups/utility/anchor/anchor_command.rs b/crates/tui/src/commands/groups/utility/anchor/anchor_command.rs new file mode 100644 index 000000000..24d87b78a --- /dev/null +++ b/crates/tui/src/commands/groups/utility/anchor/anchor_command.rs @@ -0,0 +1,33 @@ +//! Anchor command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Anchor; +impl Command for Anchor { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "anchor", + aliases: &["maodian"], + usage: "/anchor <text>", + description_id: MessageId::CmdAnchorDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::anchor::anchor(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Anchor.info(); + assert_eq!(info.name, "anchor"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/anchor.rs b/crates/tui/src/commands/groups/utility/anchor/anchor_impl.rs similarity index 99% rename from crates/tui/src/commands/anchor.rs rename to crates/tui/src/commands/groups/utility/anchor/anchor_impl.rs index 7ba66d7a1..a5fa3bfca 100644 --- a/crates/tui/src/commands/anchor.rs +++ b/crates/tui/src/commands/groups/utility/anchor/anchor_impl.rs @@ -9,7 +9,7 @@ use crate::tui::app::App; use std::fs; use std::io::Write; -use super::CommandResult; +use crate::commands::CommandResult; const USAGE: &str = "/anchor <text> | /anchor list | /anchor remove <n>"; diff --git a/crates/tui/src/commands/groups/utility/anchor/mod.rs b/crates/tui/src/commands/groups/utility/anchor/mod.rs new file mode 100644 index 000000000..fbd27f285 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/anchor/mod.rs @@ -0,0 +1,6 @@ +//! Anchor command. + +pub mod anchor_command; +pub mod anchor_impl; +pub use anchor_command::Anchor; +pub use anchor_impl::anchor; diff --git a/crates/tui/src/commands/groups/utility/hooks/hooks_command.rs b/crates/tui/src/commands/groups/utility/hooks/hooks_command.rs new file mode 100644 index 000000000..7d2aca2d0 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/hooks/hooks_command.rs @@ -0,0 +1,33 @@ +//! Hooks command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Hooks; +impl Command for Hooks { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "hooks", + aliases: &["hook", "gouzi"], + usage: "/hooks [list|events]", + description_id: MessageId::CmdHooksDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::hooks::hooks(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Hooks.info(); + assert_eq!(info.name, "hooks"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/hooks.rs b/crates/tui/src/commands/groups/utility/hooks/hooks_impl.rs similarity index 99% rename from crates/tui/src/commands/hooks.rs rename to crates/tui/src/commands/groups/utility/hooks/hooks_impl.rs index e837e477c..e48efb4da 100644 --- a/crates/tui/src/commands/hooks.rs +++ b/crates/tui/src/commands/groups/utility/hooks/hooks_impl.rs @@ -9,7 +9,7 @@ use crate::hooks::HookEvent; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; /// Top-level dispatch for `/hooks`. Subcommands: /// diff --git a/crates/tui/src/commands/groups/utility/hooks/mod.rs b/crates/tui/src/commands/groups/utility/hooks/mod.rs new file mode 100644 index 000000000..1a4019551 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/hooks/mod.rs @@ -0,0 +1,8 @@ +//! Hooks command. +//! +//! This module separates the command handler from the implementation. + +pub mod hooks_command; +pub mod hooks_impl; +pub use hooks_command::Hooks; +pub use hooks_impl::hooks; diff --git a/crates/tui/src/commands/groups/utility/jobs/jobs_command.rs b/crates/tui/src/commands/groups/utility/jobs/jobs_command.rs new file mode 100644 index 000000000..07d02b6c1 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/jobs/jobs_command.rs @@ -0,0 +1,33 @@ +//! Jobs command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Jobs; +impl Command for Jobs { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "jobs", + aliases: &["job", "zuoye"], + usage: "/jobs", + description_id: MessageId::CmdJobsDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::jobs::jobs(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Jobs.info(); + assert_eq!(info.name, "jobs"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/jobs.rs b/crates/tui/src/commands/groups/utility/jobs/jobs_impl.rs similarity index 99% rename from crates/tui/src/commands/jobs.rs rename to crates/tui/src/commands/groups/utility/jobs/jobs_impl.rs index fa31dc31a..0e5357b76 100644 --- a/crates/tui/src/commands/jobs.rs +++ b/crates/tui/src/commands/groups/utility/jobs/jobs_impl.rs @@ -2,7 +2,7 @@ use crate::tui::app::{App, AppAction, ShellJobAction}; -use super::CommandResult; +use crate::commands::CommandResult; pub fn jobs(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/groups/utility/jobs/mod.rs b/crates/tui/src/commands/groups/utility/jobs/mod.rs new file mode 100644 index 000000000..5cdcd3722 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/jobs/mod.rs @@ -0,0 +1,8 @@ +//! Jobs command. +//! +//! This module separates the command handler from the implementation. + +pub mod jobs_command; +pub mod jobs_impl; +pub use jobs_command::Jobs; +pub use jobs_impl::jobs; diff --git a/crates/tui/src/commands/groups/utility/mcp/mcp_command.rs b/crates/tui/src/commands/groups/utility/mcp/mcp_command.rs new file mode 100644 index 000000000..7cc2d9974 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/mcp/mcp_command.rs @@ -0,0 +1,33 @@ +//! Mcp command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Mcp; +impl Command for Mcp { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "mcp", + aliases: &[], + usage: "/mcp [list|restart|stop|start|add|remove]", + description_id: MessageId::CmdMcpDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::mcp::mcp(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Mcp.info(); + assert_eq!(info.name, "mcp"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/mcp.rs b/crates/tui/src/commands/groups/utility/mcp/mcp_impl.rs similarity index 99% rename from crates/tui/src/commands/mcp.rs rename to crates/tui/src/commands/groups/utility/mcp/mcp_impl.rs index 7edf95000..fa7879038 100644 --- a/crates/tui/src/commands/mcp.rs +++ b/crates/tui/src/commands/groups/utility/mcp/mcp_impl.rs @@ -2,7 +2,7 @@ use crate::tui::app::{App, AppAction, McpUiAction}; -use super::CommandResult; +use crate::commands::CommandResult; pub fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/groups/utility/mcp/mod.rs b/crates/tui/src/commands/groups/utility/mcp/mod.rs new file mode 100644 index 000000000..8aec7a587 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/mcp/mod.rs @@ -0,0 +1,8 @@ +//! Mcp command. +//! +//! This module separates the command handler from the implementation. + +pub mod mcp_command; +pub mod mcp_impl; +pub use mcp_command::Mcp; +pub use mcp_impl::mcp; diff --git a/crates/tui/src/commands/groups/utility/mod.rs b/crates/tui/src/commands/groups/utility/mod.rs new file mode 100644 index 000000000..abe7c67a3 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/mod.rs @@ -0,0 +1,47 @@ +//! Utility commands group barrel. +//! +//! Each command lives in its own file under this directory. +//! This module declares the submodules and provides the `CommandGroup` +//! implementation that collects them. + +pub(crate) mod anchor; +pub(crate) mod hooks; +pub(crate) mod jobs; +pub(crate) mod mcp; +pub(crate) mod network; +pub(crate) mod queue; +pub(crate) mod rlm; +pub(crate) mod slop; +pub(crate) mod stash; +pub(crate) mod task; + +use crate::commands::traits::{Command, CommandGroup}; + +use self::anchor::Anchor; +use self::hooks::Hooks; +use self::jobs::Jobs; +use self::mcp::Mcp; +use self::network::Network; +use self::queue::Queue; +use self::rlm::Rlm; +use self::slop::Slop; +use self::stash::Stash; +use self::task::Task; + +pub struct UtilityCommands; +impl CommandGroup for UtilityCommands { + fn commands(&self) -> Vec<Box<dyn Command>> { + vec![ + Box::new(Queue), + Box::new(Stash), + Box::new(Hooks), + Box::new(Anchor), + Box::new(Network), + Box::new(Mcp), + Box::new(Rlm), + Box::new(Task), + Box::new(Jobs), + Box::new(Slop), + ] + } +} diff --git a/crates/tui/src/commands/groups/utility/network/mod.rs b/crates/tui/src/commands/groups/utility/network/mod.rs new file mode 100644 index 000000000..4ede04097 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/network/mod.rs @@ -0,0 +1,8 @@ +//! Network command. +//! +//! This module separates the command handler from the implementation. + +pub mod network_command; +pub mod network_impl; +pub use network_command::Network; +pub use network_impl::network; diff --git a/crates/tui/src/commands/groups/utility/network/network_command.rs b/crates/tui/src/commands/groups/utility/network/network_command.rs new file mode 100644 index 000000000..3aa388ac4 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/network/network_command.rs @@ -0,0 +1,33 @@ +//! Network command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Network; +impl Command for Network { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "network", + aliases: &[], + usage: "/network [allow|deny] <host>", + description_id: MessageId::CmdNetworkDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::network::network(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Network.info(); + assert_eq!(info.name, "network"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/network.rs b/crates/tui/src/commands/groups/utility/network/network_impl.rs similarity index 98% rename from crates/tui/src/commands/network.rs rename to crates/tui/src/commands/groups/utility/network/network_impl.rs index dbe0e7afe..9e6e8e4b8 100644 --- a/crates/tui/src/commands/network.rs +++ b/crates/tui/src/commands/groups/utility/network/network_impl.rs @@ -6,7 +6,7 @@ use std::path::Path; use anyhow::{Context, bail}; use toml::Value; -use super::CommandResult; +use crate::commands::CommandResult; use crate::network_policy::host_from_url; use crate::tui::app::App; @@ -70,7 +70,7 @@ enum NetworkEdit { } fn list_policy() -> anyhow::Result<String> { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let doc = load_config_doc(&path)?; let network = doc.get("network").and_then(Value::as_table); let default = network @@ -97,7 +97,7 @@ fn list_policy() -> anyhow::Result<String> { } fn update_host(edit: NetworkEdit, host: &str) -> anyhow::Result<String> { - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; @@ -136,7 +136,7 @@ fn update_default(value: &str) -> anyhow::Result<String> { _ => bail!("Usage: /network default <allow|deny|prompt>"), }; - let path = super::config::config_toml_path(None)?; + let path = crate::config_persistence::config_toml_path(None)?; let mut doc = load_config_doc(&path)?; let network = network_table_mut(&mut doc)?; network.insert("default".to_string(), Value::String(normalized.to_string())); diff --git a/crates/tui/src/commands/groups/utility/queue/mod.rs b/crates/tui/src/commands/groups/utility/queue/mod.rs new file mode 100644 index 000000000..87aea8094 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/queue/mod.rs @@ -0,0 +1,8 @@ +//! Queue command. +//! +//! This module separates the command handler from the implementation. + +pub mod queue_command; +pub mod queue_impl; +pub use queue_command::Queue; +pub use queue_impl::queue; diff --git a/crates/tui/src/commands/groups/utility/queue/queue_command.rs b/crates/tui/src/commands/groups/utility/queue/queue_command.rs new file mode 100644 index 000000000..314fbfd02 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/queue/queue_command.rs @@ -0,0 +1,33 @@ +//! Queue command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Queue; +impl Command for Queue { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "queue", + aliases: &["queued"], + usage: "/queue [list|edit <n>|drop <n>|clear]", + description_id: MessageId::CmdQueueDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::queue::queue(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Queue.info(); + assert_eq!(info.name, "queue"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/queue.rs b/crates/tui/src/commands/groups/utility/queue/queue_impl.rs similarity index 99% rename from crates/tui/src/commands/queue.rs rename to crates/tui/src/commands/groups/utility/queue/queue_impl.rs index 51bf2b7db..4611a65b5 100644 --- a/crates/tui/src/commands/queue.rs +++ b/crates/tui/src/commands/groups/utility/queue/queue_impl.rs @@ -3,7 +3,7 @@ use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; const PREVIEW_LIMIT: usize = 120; diff --git a/crates/tui/src/commands/groups/utility/rlm/mod.rs b/crates/tui/src/commands/groups/utility/rlm/mod.rs new file mode 100644 index 000000000..06e97850e --- /dev/null +++ b/crates/tui/src/commands/groups/utility/rlm/mod.rs @@ -0,0 +1,5 @@ +//! RLM command. + +pub mod rlm_command; +pub mod rlm_impl; +pub use rlm_command::Rlm; diff --git a/crates/tui/src/commands/groups/utility/rlm/rlm_command.rs b/crates/tui/src/commands/groups/utility/rlm/rlm_command.rs new file mode 100644 index 000000000..61a3c82dc --- /dev/null +++ b/crates/tui/src/commands/groups/utility/rlm/rlm_command.rs @@ -0,0 +1,55 @@ +//! RLM command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Rlm; +impl Command for Rlm { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "rlm", + aliases: &["recursive", "digui"], + usage: "/rlm [N] <file_or_text>", + description_id: MessageId::CmdRlmDescription, + } + } + + fn execute(&self, app: &mut App, arg: Option<&str>) -> CommandResult { + super::rlm_impl::rlm(app, arg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Rlm.info(); + assert_eq!(info.name, "rlm"); + assert_eq!(info.usage, "/rlm [N] <file_or_text>"); + assert!(info.aliases.contains(&"recursive")); + } + + #[test] + fn execute_requires_target() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Rlm.execute(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /rlm")); + } + + #[test] + fn execute_sends_rlm_open_instruction() { + let mut app = crate::commands::groups::test_support::test_app(); + let result = Rlm.execute(&mut app, Some("2 inspect this text")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("depth 2")); + let action = result.action.expect("expected send action"); + assert!( + matches!(action, crate::tui::app::AppAction::SendMessage(message) if message.contains("rlm_open") && message.contains("inspect this text")) + ); + } +} diff --git a/crates/tui/src/commands/groups/utility/rlm/rlm_impl.rs b/crates/tui/src/commands/groups/utility/rlm/rlm_impl.rs new file mode 100644 index 000000000..d3c00eb1d --- /dev/null +++ b/crates/tui/src/commands/groups/utility/rlm/rlm_impl.rs @@ -0,0 +1,62 @@ +use crate::commands::CommandResult; +use crate::tui::app::{App, AppAction}; + +pub(crate) fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, target) = match parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let target = match target { + Some(path) if !path.trim().is_empty() => path.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /rlm [N] <file_or_text>\n\n\ + Opens a persistent RLM context with sub_rlm depth N (0-3, default 1).", + ); + } + }; + let source_arg = if resolves_to_existing_file(app, &target) { + format!("file_path: \"{target}\"") + } else { + format!("content: {target:?}") + }; + let message = format!( + "Open and use a persistent RLM session. Call `rlm_open` with name `slash_rlm` and {source_arg}. Call `rlm_configure` with `sub_rlm_max_depth: {max_depth}`." + ); + CommandResult::with_message_and_action( + format!("Opening persistent RLM context at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} + +fn parse_depth_prefixed_arg( + arg: Option<&str>, + default_depth: u32, +) -> Result<(u32, Option<&str>), String> { + let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok((default_depth, None)); + }; + let mut parts = raw.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default(); + if first.chars().all(|ch| ch.is_ascii_digit()) { + let depth: u32 = first + .parse() + .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; + if depth > 3 { + return Err("Depth must be between 0 and 3".to_string()); + } + Ok((depth, parts.next().map(str::trim))) + } else { + Ok((default_depth, Some(raw))) + } +} + +fn resolves_to_existing_file(app: &App, input: &str) -> bool { + let path = std::path::Path::new(input); + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + app.workspace.join(path) + }; + candidate.is_file() +} diff --git a/crates/tui/src/commands/groups/utility/slop/mod.rs b/crates/tui/src/commands/groups/utility/slop/mod.rs new file mode 100644 index 000000000..6fcfc51e5 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/slop/mod.rs @@ -0,0 +1,5 @@ +//! Slop command. + +pub mod slop_command; +pub mod slop_impl; +pub use slop_command::Slop; diff --git a/crates/tui/src/commands/groups/utility/slop/slop_command.rs b/crates/tui/src/commands/groups/utility/slop/slop_command.rs new file mode 100644 index 000000000..c92ad45d3 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/slop/slop_command.rs @@ -0,0 +1,33 @@ +//! Slop command. + +use super::slop_impl::slop; +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Slop; +impl Command for Slop { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "slop", + aliases: &["canzha"], + usage: "/slop [query|export]", + description_id: MessageId::CmdSlopDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + slop(app, args) + } +} +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Slop.info(); + assert_eq!(info.name, "slop"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/groups/utility/slop/slop_impl.rs b/crates/tui/src/commands/groups/utility/slop/slop_impl.rs new file mode 100644 index 000000000..b38aee9ff --- /dev/null +++ b/crates/tui/src/commands/groups/utility/slop/slop_impl.rs @@ -0,0 +1,40 @@ +use crate::commands::CommandResult; +use crate::tui::app::App; + +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." + )), + } +} diff --git a/crates/tui/src/commands/groups/utility/stash/mod.rs b/crates/tui/src/commands/groups/utility/stash/mod.rs new file mode 100644 index 000000000..6b01c954b --- /dev/null +++ b/crates/tui/src/commands/groups/utility/stash/mod.rs @@ -0,0 +1,8 @@ +//! Stash command. +//! +//! This module separates the command handler from the implementation. + +pub mod stash_command; +pub mod stash_impl; +pub use stash_command::Stash; +pub use stash_impl::stash; diff --git a/crates/tui/src/commands/groups/utility/stash/stash_command.rs b/crates/tui/src/commands/groups/utility/stash/stash_command.rs new file mode 100644 index 000000000..ade3d5f9f --- /dev/null +++ b/crates/tui/src/commands/groups/utility/stash/stash_command.rs @@ -0,0 +1,33 @@ +//! Stash command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Stash; +impl Command for Stash { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "stash", + aliases: &["park"], + usage: "/stash [list|pop|clear]", + description_id: MessageId::CmdStashDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::stash::stash(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Stash.info(); + assert_eq!(info.name, "stash"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/stash.rs b/crates/tui/src/commands/groups/utility/stash/stash_impl.rs similarity index 99% rename from crates/tui/src/commands/stash.rs rename to crates/tui/src/commands/groups/utility/stash/stash_impl.rs index 1723e4403..4680de8dd 100644 --- a/crates/tui/src/commands/stash.rs +++ b/crates/tui/src/commands/groups/utility/stash/stash_impl.rs @@ -8,7 +8,7 @@ use crate::composer_stash; use crate::tui::app::App; -use super::CommandResult; +use crate::commands::CommandResult; /// Top-level dispatch for `/stash`. Subcommands: /// diff --git a/crates/tui/src/commands/groups/utility/task/mod.rs b/crates/tui/src/commands/groups/utility/task/mod.rs new file mode 100644 index 000000000..9ffba899a --- /dev/null +++ b/crates/tui/src/commands/groups/utility/task/mod.rs @@ -0,0 +1,8 @@ +//! Task command. +//! +//! This module separates the command handler from the implementation. + +pub mod task_command; +pub mod task_impl; +pub use task_command::Task; +pub use task_impl::task; diff --git a/crates/tui/src/commands/groups/utility/task/task_command.rs b/crates/tui/src/commands/groups/utility/task/task_command.rs new file mode 100644 index 000000000..611b05926 --- /dev/null +++ b/crates/tui/src/commands/groups/utility/task/task_command.rs @@ -0,0 +1,33 @@ +//! Task command. + +use crate::commands::CommandResult; +use crate::commands::traits::{Command, CommandInfo}; +use crate::localization::MessageId; +use crate::tui::app::App; + +pub struct Task; +impl Command for Task { + fn info(&self) -> &'static CommandInfo { + &CommandInfo { + name: "task", + aliases: &["tasks"], + usage: "/task [list|read|revert|cancel]", + description_id: MessageId::CmdTaskDescription, + } + } + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult { + crate::commands::groups::utility::task::task(app, args) + } +} + +#[cfg(test)] +mod command_metadata_tests { + use super::*; + + #[test] + fn info_returns_metadata() { + let info = Task.info(); + assert_eq!(info.name, "task"); + assert!(!info.usage.is_empty()); + } +} diff --git a/crates/tui/src/commands/task.rs b/crates/tui/src/commands/groups/utility/task/task_impl.rs similarity index 98% rename from crates/tui/src/commands/task.rs rename to crates/tui/src/commands/groups/utility/task/task_impl.rs index c96fe29a1..efeaf0ad6 100644 --- a/crates/tui/src/commands/task.rs +++ b/crates/tui/src/commands/groups/utility/task/task_impl.rs @@ -2,7 +2,7 @@ use crate::tui::app::{App, AppAction}; -use super::CommandResult; +use crate::commands::CommandResult; pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult { let raw = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9a953be62..c8eca05b7 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -1,42 +1,25 @@ //! Slash command registry and dispatch system //! -//! This module provides a modular command system inspired by Codex-rs. -//! Commands are organized by category and dispatched through a central registry. +//! This module provides a modular command system built on the strategy pattern. +//! Commands are organized by logical group (Core, Session, Config, …), each +//! group lives in its own file, and the central registry collects them all. +//! `mod.rs` only registers groups and dispatches commands — it contains zero +//! command-specific code. -mod anchor; -mod attachment; -mod balance; -mod change; -mod config; -mod core; -mod debug; -mod feedback; -mod goal; -mod hooks; -mod init; -mod jobs; -mod mcp; -mod memory; -mod network; -mod note; -mod provider; -mod queue; -mod rename; -mod restore; -mod review; -mod session; -pub mod share; -mod skills; -mod stash; -mod status; -mod task; +pub mod traits; pub mod user_commands; -use std::fmt::Write as _; +// Group modules — each registers its commands into the registry. +// Individual groups are declared in groups/mod.rs. +pub(crate) mod groups; + +use std::sync::OnceLock; -use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::{App, AppAction}; +#[allow(unused_imports)] +pub use traits::CommandInfo; + /// Result of executing a command #[derive(Debug, Clone)] pub struct CommandResult { @@ -49,7 +32,6 @@ pub struct CommandResult { } impl CommandResult { - /// Create an empty result (command succeeded with no output) pub fn ok() -> Self { Self { message: None, @@ -57,8 +39,6 @@ impl CommandResult { is_error: false, } } - - /// Create a result with just a message pub fn message(msg: impl Into<String>) -> Self { Self { message: Some(msg.into()), @@ -66,8 +46,6 @@ impl CommandResult { is_error: false, } } - - /// Create a result with an action pub fn action(action: AppAction) -> Self { Self { message: None, @@ -75,9 +53,6 @@ impl CommandResult { is_error: false, } } - - /// Create a result with both message and action - #[allow(dead_code)] pub fn with_message_and_action(msg: impl Into<String>, action: AppAction) -> Self { Self { message: Some(msg.into()), @@ -85,8 +60,6 @@ impl CommandResult { is_error: false, } } - - /// Create an error message result pub fn error(msg: impl Into<String>) -> Self { Self { message: Some(format!("Error: {}", msg.into())), @@ -96,900 +69,70 @@ impl CommandResult { } } -/// Command metadata for help and autocomplete. -/// -/// The English description lives in `localization::english` (private), keyed -/// by `description_id`. Callers resolve a localized description through -/// [`CommandInfo::description_for`] which delegates to -/// [`crate::localization::tr`]. -#[derive(Debug, Clone, Copy)] -pub struct CommandInfo { - pub name: &'static str, - pub aliases: &'static [&'static str], - pub usage: &'static str, - pub description_id: MessageId, -} - -impl CommandInfo { - pub fn requires_argument(&self) -> bool { - self.usage.contains('<') || self.usage.contains('[') - } +// ── Registry access ──────────────────────────────────────────────────────── - pub fn palette_command(&self) -> String { - if self.requires_argument() { - format!("/{} ", self.name) - } else { - format!("/{}", self.name) - } - } +/// Access the global command registry (lazily initialised). +static REGISTRY: OnceLock<traits::CommandRegistry> = OnceLock::new(); - pub fn description_for(&self, locale: Locale) -> &'static str { - tr(locale, self.description_id) +fn build_registry() -> traits::CommandRegistry { + let mut reg = traits::CommandRegistry::empty(); + for group in groups::all_command_groups() { + reg.register_group(group); } + reg +} - pub fn palette_description_for(&self, locale: Locale) -> String { - let desc = self.description_for(locale); - if self.aliases.is_empty() { - desc.to_string() - } else { - format!("{} aliases: {}", desc, self.aliases.join(", ")) - } - } +pub fn registry() -> &'static traits::CommandRegistry { + REGISTRY.get_or_init(build_registry) } -/// All registered commands -pub const COMMANDS: &[CommandInfo] = &[ - // Core commands - CommandInfo { - name: "anchor", - aliases: &["maodian"], - usage: "/anchor <text> | /anchor list | /anchor remove <n>", - description_id: MessageId::CmdAnchorDescription, - }, - CommandInfo { - name: "help", - aliases: &["?", "bangzhu", "帮助"], - usage: "/help [command]", - description_id: MessageId::CmdHelpDescription, - }, - CommandInfo { - name: "clear", - aliases: &["qingping"], - usage: "/clear", - description_id: MessageId::CmdClearDescription, - }, - CommandInfo { - name: "exit", - aliases: &["quit", "q", "tuichu"], - usage: "/exit", - description_id: MessageId::CmdExitDescription, - }, - CommandInfo { - name: "model", - aliases: &["moxing"], - usage: "/model [name]", - description_id: MessageId::CmdModelDescription, - }, - CommandInfo { - name: "models", - aliases: &["moxingliebiao"], - usage: "/models", - description_id: MessageId::CmdModelsDescription, - }, - CommandInfo { - name: "provider", - aliases: &[], - usage: "/provider [name] [model]", - description_id: MessageId::CmdProviderDescription, - }, - CommandInfo { - name: "queue", - aliases: &["queued"], - usage: "/queue [list|edit <n>|drop <n>|clear]", - description_id: MessageId::CmdQueueDescription, - }, - CommandInfo { - name: "stash", - aliases: &["park"], - usage: "/stash [list|pop|clear]", - description_id: MessageId::CmdStashDescription, - }, - CommandInfo { - name: "hooks", - aliases: &["hook", "gouzi"], - usage: "/hooks [list|events]", - description_id: MessageId::CmdHooksDescription, - }, - CommandInfo { - name: "subagents", - aliases: &["agents", "zhinengti"], - usage: "/subagents", - description_id: MessageId::CmdSubagentsDescription, - }, - CommandInfo { - name: "agent", - aliases: &["daili"], - usage: "/agent [N] <task>", - description_id: MessageId::CmdAgentDescription, - }, - CommandInfo { - name: "links", - aliases: &["dashboard", "api", "lianjie"], - usage: "/links", - description_id: MessageId::CmdLinksDescription, - }, - CommandInfo { - name: "feedback", - aliases: &[], - usage: "/feedback [bug|feature|security]", - description_id: MessageId::CmdFeedbackDescription, - }, - CommandInfo { - name: "home", - aliases: &["stats", "overview", "zhuye", "shouye"], - usage: "/home", - description_id: MessageId::CmdHomeDescription, - }, - CommandInfo { - name: "workspace", - aliases: &["cwd"], - usage: "/workspace [path]", - description_id: MessageId::CmdWorkspaceDescription, - }, - CommandInfo { - name: "note", - aliases: &[], - usage: "/note [add|list|show|edit|remove|clear|path]", - description_id: MessageId::CmdNoteDescription, - }, - CommandInfo { - name: "memory", - aliases: &[], - usage: "/memory [show|path|clear|edit|help]", - description_id: MessageId::CmdMemoryDescription, - }, - CommandInfo { - name: "attach", - aliases: &["image", "media", "fujian"], - usage: "/attach <path>", - description_id: MessageId::CmdAttachDescription, - }, - CommandInfo { - name: "task", - aliases: &["tasks"], - usage: "/task [add <prompt>|list|show <id>|cancel <id>]", - description_id: MessageId::CmdTaskDescription, - }, - CommandInfo { - name: "jobs", - aliases: &["job", "zuoye"], - usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]", - description_id: MessageId::CmdJobsDescription, - }, - CommandInfo { - name: "mcp", - aliases: &[], - usage: "/mcp [init|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|validate|reload]", - description_id: MessageId::CmdMcpDescription, - }, - CommandInfo { - name: "network", - aliases: &[], - usage: "/network [list|allow <host>|deny <host>|remove <host>|default <allow|deny|prompt>]", - description_id: MessageId::CmdNetworkDescription, - }, - // Session commands - CommandInfo { - name: "rename", - aliases: &["gaiming", "chongmingming"], - usage: "/rename <new title>", - description_id: MessageId::CmdRenameDescription, - }, - CommandInfo { - name: "save", - aliases: &[], - usage: "/save [path]", - description_id: MessageId::CmdSaveDescription, - }, - CommandInfo { - name: "fork", - aliases: &["branch"], - usage: "/fork", - description_id: MessageId::CmdForkDescription, - }, - CommandInfo { - name: "new", - aliases: &[], - usage: "/new [--force]", - description_id: MessageId::CmdNewDescription, - }, - CommandInfo { - name: "sessions", - aliases: &["resume"], - usage: "/sessions [show|prune <days>]", - description_id: MessageId::CmdSessionsDescription, - }, - CommandInfo { - name: "load", - aliases: &["jiazai"], - usage: "/load [path]", - description_id: MessageId::CmdLoadDescription, - }, - CommandInfo { - name: "compact", - aliases: &["yasuo"], - usage: "/compact", - description_id: MessageId::CmdCompactDescription, - }, - CommandInfo { - name: "purge", - aliases: &["qingchu"], - usage: "/purge", - description_id: MessageId::CmdPurgeDescription, - }, - CommandInfo { - name: "relay", - aliases: &["batonpass", "接力"], - usage: "/relay [focus]", - description_id: MessageId::CmdRelayDescription, - }, - CommandInfo { - name: "context", - aliases: &["ctx"], - usage: "/context", - description_id: MessageId::CmdContextDescription, - }, - CommandInfo { - name: "export", - aliases: &["daochu"], - usage: "/export [path]", - description_id: MessageId::CmdExportDescription, - }, - // Config commands - CommandInfo { - name: "config", - aliases: &[], - usage: "/config", - description_id: MessageId::CmdConfigDescription, - }, - CommandInfo { - name: "mode", - aliases: &["jihua", "zidong"], - usage: "/mode [agent|plan|yolo|1|2|3]", - description_id: MessageId::CmdModeDescription, - }, - CommandInfo { - name: "theme", - aliases: &[], - usage: "/theme [name]", - description_id: MessageId::CmdThemeDescription, - }, - CommandInfo { - name: "verbose", - aliases: &[], - usage: "/verbose [on|off]", - description_id: MessageId::CmdVerboseDescription, - }, - CommandInfo { - name: "trust", - aliases: &["xinren"], - usage: "/trust [on|off|add <path>|remove <path>|list]", - description_id: MessageId::CmdTrustDescription, - }, - CommandInfo { - name: "logout", - aliases: &[], - usage: "/logout", - description_id: MessageId::CmdLogoutDescription, - }, - // Debug commands - CommandInfo { - name: "tokens", - aliases: &[], - usage: "/tokens", - description_id: MessageId::CmdTokensDescription, - }, - CommandInfo { - name: "translate", - aliases: &["translation", "transale"], - usage: "/translate", - description_id: MessageId::CmdTranslateDescription, - }, - CommandInfo { - name: "system", - aliases: &["xitong"], - usage: "/system", - description_id: MessageId::CmdSystemDescription, - }, - CommandInfo { - name: "edit", - aliases: &[], - usage: "/edit", - description_id: MessageId::CmdEditDescription, - }, - CommandInfo { - name: "diff", - aliases: &[], - usage: "/diff", - description_id: MessageId::CmdDiffDescription, - }, - CommandInfo { - name: "change", - aliases: &[], - usage: "/change [version]", - description_id: MessageId::CmdChangeDescription, - }, - CommandInfo { - name: "undo", - aliases: &[], - usage: "/undo", - description_id: MessageId::CmdUndoDescription, - }, - CommandInfo { - name: "retry", - aliases: &["chongshi"], - usage: "/retry", - description_id: MessageId::CmdRetryDescription, - }, - CommandInfo { - name: "init", - aliases: &[], - usage: "/init", - description_id: MessageId::CmdInitDescription, - }, - CommandInfo { - name: "lsp", - aliases: &[], - usage: "/lsp [on|off|status]", - description_id: MessageId::CmdLspDescription, - }, - CommandInfo { - name: "share", - aliases: &[], - usage: "/share", - description_id: MessageId::CmdShareDescription, - }, - CommandInfo { - name: "hunt", - aliases: &["goal", "mubiao", "狩猎"], - usage: "/hunt [quarry] [budget: N]", - description_id: MessageId::CmdGoalDescription, - }, - CommandInfo { - name: "settings", - aliases: &[], - usage: "/settings", - description_id: MessageId::CmdSettingsDescription, - }, - CommandInfo { - name: "status", - aliases: &[], - usage: "/status", - description_id: MessageId::CmdStatusDescription, - }, - CommandInfo { - name: "statusline", - aliases: &[], - usage: "/statusline", - description_id: MessageId::CmdStatuslineDescription, - }, - // Skills commands - CommandInfo { - name: "skills", - aliases: &["jinengliebiao"], - usage: "/skills [--remote|sync|<prefix>]", - description_id: MessageId::CmdSkillsDescription, - }, - CommandInfo { - name: "skill", - aliases: &["jineng"], - usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>", - description_id: MessageId::CmdSkillDescription, - }, - CommandInfo { - name: "review", - aliases: &["shencha"], - usage: "/review <target>", - description_id: MessageId::CmdReviewDescription, - }, - CommandInfo { - name: "restore", - aliases: &[], - usage: "/restore [N]", - description_id: MessageId::CmdRestoreDescription, - }, - // RLM command - CommandInfo { - name: "rlm", - aliases: &["recursive", "digui"], - usage: "/rlm [N] <file_or_text>", - description_id: MessageId::CmdRlmDescription, - }, - // Debug/cost command - CommandInfo { - name: "cost", - aliases: &[], - usage: "/cost", - description_id: MessageId::CmdCostDescription, - }, - // Balance query (#2019) - CommandInfo { - name: "balance", - aliases: &[], - usage: "/balance", - description_id: MessageId::CmdBalanceDescription, - }, - // Profile switching (#390) - CommandInfo { - name: "profile", - aliases: &["dangan"], - usage: "/profile <name>", - description_id: MessageId::CmdHelpDescription, // reuse for now - }, - // Cache telemetry (#263) - CommandInfo { - name: "cache", - aliases: &[], - usage: "/cache [count|inspect|stats|zones|warmup]", - description_id: MessageId::CmdCacheDescription, - }, - // Slop Ledger (#2127) - CommandInfo { - name: "slop", - aliases: &["canzha"], - usage: "/slop [query|export]", - description_id: MessageId::CmdSlopDescription, - }, -]; +// ── Dispatch ─────────────────────────────────────────────────────────────── -/// Execute a slash command +/// Execute a slash command. +/// +/// Parses `cmd` (e.g. `/help` or `/help model`), looks up the command in +/// the registry, and runs it. User-defined commands are checked first so +/// they can shadow built-ins. pub fn execute(cmd: &str, app: &mut App) -> CommandResult { - let parts: Vec<&str> = cmd.trim().splitn(2, ' ').collect(); + let trimmed = cmd.trim(); + let parts: Vec<&str> = trimmed.splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let command = command.strip_prefix('/').unwrap_or(&command); let arg = parts.get(1).map(|s| s.trim()); - // Check user-defined commands FIRST so they can override built-ins. - if let Some(result) = user_commands::try_dispatch_user_command(app, cmd.trim()) { + // User-defined commands FIRST so they can override built-ins. + if let Some(result) = user_commands::try_dispatch_user_command(app, trimmed) { return result; } - // Match command or alias - match command { - // Core commands - "anchor" | "maodian" => anchor::anchor(app, arg), - "help" | "?" | "bangzhu" | "帮助" => core::help(app, arg), - "clear" | "qingping" => core::clear(app), - "exit" | "quit" | "q" | "tuichu" => core::exit(), - "model" | "moxing" => core::model(app, arg), - "models" | "moxingliebiao" => core::models(app), - "provider" => provider::provider(app, arg), - "queue" | "queued" => queue::queue(app, arg), - "stash" | "park" => stash::stash(app, arg), - "hooks" | "hook" | "gouzi" => hooks::hooks(app, arg), - "subagents" | "agents" | "zhinengti" => core::subagents(app), - "agent" | "daili" => agent(app, arg), - "links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app), - "feedback" => feedback::feedback(app, arg), - "home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app), - "workspace" | "cwd" => core::workspace_switch(app, arg), - "note" => note::note(app, arg), - "memory" => memory::memory(app, arg), - "attach" | "image" | "media" | "fujian" => attachment::attach(app, arg), - "task" | "tasks" => task::task(app, arg), - "jobs" | "job" | "zuoye" => jobs::jobs(app, arg), - "mcp" => mcp::mcp(app, arg), - "network" => network::network(app, arg), - - // Session commands - "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), - "save" => session::save(app, arg), - "fork" | "branch" => session::fork(app), - "new" => session::new_session(app, arg), - "sessions" | "resume" => session::sessions(app, arg), - "relay" | "batonpass" | "接力" => relay(app, arg), - "load" | "jiazai" => session::load(app, arg), - "compact" | "yasuo" => session::compact(app), - "purge" | "qingchu" => session::purge(app), - "export" | "daochu" => session::export(app, arg), - - // Config commands - "config" => config::config_command(app, arg), - "settings" => config::show_settings(app), - "status" => status::status(app), - "statusline" => config::status_line(app), - "mode" => config::mode(app, arg), - "jihua" => config::mode(app, Some("plan")), - "zidong" => config::mode(app, Some("yolo")), - "theme" => config::theme(app, arg), - "verbose" => config::verbose(app, arg), - "trust" | "xinren" => config::trust(app, arg), - "logout" => config::logout(app), - - // Debug commands - "translate" | "translation" | "transale" => core::translate(app), - "tokens" => debug::tokens(app), - "cost" => debug::cost(app), - "balance" => balance::balance(app), - "cache" => debug::cache(app, arg), - - // Slop ledger (#2127) - "slop" | "canzha" => config::slop(app, arg), - - // ChangeLog command - "change" => change::change(app, arg), - "system" | "xitong" => debug::system_prompt(app), - "context" | "ctx" => debug::context(app), - "edit" => debug::edit(app), - "diff" => debug::diff(app), - "undo" => { - // Try surgical patch-undo first; fall back to conversation undo - // if no snapshots are available or if the snapshot undo couldn't - // find anything useful. - let result = debug::patch_undo(app); - if result.message.as_deref().is_none_or(|m| { - m.starts_with("No snapshots found") - || m.starts_with("No tool or pre-turn") - || m.starts_with("Snapshot repo") - }) { - debug::undo_conversation(app) - } else { - result - } - } - "retry" | "chongshi" => debug::retry(app), - - // Project commands - "init" => init::init(app), - "lsp" => config::lsp_command(app, arg), - "share" => share::share(app, arg), - "goal" | "hunt" | "mubiao" | "狩猎" => goal::hunt(app, arg), - - // Skills commands - "skills" | "jinengliebiao" => skills::list_skills(app, arg), - "skill" | "jineng" => skills::run_skill(app, arg), - "review" | "shencha" => review::review(app, arg), - "restore" => restore::restore(app, arg), - - // Profile switch (#390) - "profile" | "dangan" => core::profile_switch(app, arg), - - // RLM command - "rlm" | "recursive" | "digui" => rlm(app, arg), - - // Legacy command migrations (kept out of registry/autocomplete intentionally). - "set" => CommandResult::error( - "The /set command was retired. Use /config to edit settings and /settings to inspect current values.", - ), - "deepseek" => CommandResult::error( - "The /deepseek command was renamed. Use /links (aliases: /dashboard, /api).", - ), - - _ => { - // Third source: skills (lowest precedence after native and user-config). - // Try to run a skill whose name matches the command. - if skills::run_skill_by_name(app, command, arg).is_some() { - return skills::run_skill_by_name(app, command, arg).unwrap(); - } - let suggestions = suggest_command_names(command, 3); - if suggestions.is_empty() { - CommandResult::error(format!( - "Unknown command: /{command}. Type /help for available commands." - )) - } else { - let list = suggestions - .into_iter() - .map(|name| format!("/{name}")) - .collect::<Vec<_>>() - .join(", "); - CommandResult::error(format!( - "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." - )) - } - } - } -} - -/// Update a configuration value programmatically (used by interactive UI views). -pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { - config::set_config_value(app, key, value, persist) -} - -/// Persist the user's chosen footer items to `~/.deepseek/config.toml` under -/// `tui.status_items`. See [`config::persist_status_items`] for details. -pub fn persist_status_items( - items: &[crate::config::StatusItem], -) -> anyhow::Result<std::path::PathBuf> { - config::persist_status_items(items) -} - -/// Persist a root-level string key in `config.toml`. -pub fn persist_root_string_key( - config_path: Option<&std::path::Path>, - key: &str, - value: &str, -) -> anyhow::Result<std::path::PathBuf> { - config::persist_root_string_key(config_path, key, value) -} - -pub fn switch_mode(app: &mut App, mode: crate::tui::app::AppMode) -> String { - config::switch_mode(app, mode) -} - -/// Auto-select a model based on request complexity. -pub fn auto_model_heuristic(input: &str, current_model: &str) -> String { - config::auto_model_heuristic(input, current_model) -} - -pub use config::{ - AutoRouteRecommendation, AutoRouteSelection, normalize_auto_route_effort, - parse_auto_route_recommendation, resolve_auto_route_with_flash, -}; - -/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from -/// Zhang et al. (arXiv:2512.24601). -/// -/// The user's prompt text is passed as the argument. It will be stored -/// in the REPL as the `PROMPT` variable. The root LLM will only see -/// metadata about the REPL state, never the prompt text directly. -pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { - let (max_depth, target) = match parse_depth_prefixed_arg(arg, 1) { - Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), - }; - let target = match target { - Some(p) if !p.trim().is_empty() => p.trim().to_string(), - _ => { - return CommandResult::error( - "Usage: /rlm [N] <file_or_text>\n\n\ - Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." - .to_string(), - ); - } - }; - - let source_arg = if resolves_to_existing_file(app, &target) { - format!(r#"file_path: "{target}""#) - } else { - format!("content: {target:?}") - }; - let message = format!( - "Open and use a persistent RLM session for this request. Call `rlm_open` with name `slash_rlm` and {source_arg}. Then call `rlm_configure` with `sub_rlm_max_depth: {max_depth}`. Use `rlm_eval` to inspect the context through `peek`, `search`, and `chunk`, and call `finalize(...)` from the REPL when ready. If a `var_handle` is returned, use `handle_read` for bounded slices or projections before answering." - ); - - CommandResult::with_message_and_action( - format!("Opening persistent RLM context at depth {max_depth}..."), - AppAction::SendMessage(message), - ) -} - -/// Open a persistent sub-agent session from a slash command. -pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { - let (max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { - Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), - }; - let task = match task { - Some(task) if !task.trim().is_empty() => task.trim().to_string(), - _ => { - return CommandResult::error( - "Usage: /agent [N] <task>\n\n\ - Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", - ); - } - }; - let message = format!( - "Open a persistent sub-agent session for this task. Call `agent_open` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `agent_eval` to wait for the next terminal/current projection and `handle_read` on the returned transcript_handle if you need more detail. Verify any claimed side effects before reporting success." - ); - CommandResult::with_message_and_action( - format!("Opening persistent sub-agent at depth {max_depth}..."), - AppAction::SendMessage(message), - ) -} - -/// Ask the active model to write a compact relay artifact for the next thread. -/// -/// The visible command is `/relay` (with `/接力` for Chinese users), but the -/// durable file path remains `.deepseek/handoff.md` for compatibility with -/// existing sessions and startup prompt loading. -pub fn relay(app: &mut App, arg: Option<&str>) -> CommandResult { - let focus = arg.map(str::trim).filter(|value| !value.is_empty()); - let message = build_relay_instruction(app, focus); - CommandResult::with_message_and_action( - "Preparing session relay at .deepseek/handoff.md...", - AppAction::SendMessage(message), - ) -} - -fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { - let mut out = String::new(); - let _ = writeln!( - out, - "Create a compact session relay (接力) for a future CodeWhale thread." - ); - let _ = writeln!(out); - let _ = writeln!(out, "Write or update `.deepseek/handoff.md`."); - let _ = writeln!( - out, - "Keep the existing file path for compatibility, but title the artifact `# Session relay`." - ); - let _ = writeln!(out); - let _ = writeln!(out, "Current session snapshot:"); - let _ = writeln!(out, "- Workspace: {}", app.workspace.display()); - let _ = writeln!(out, "- Mode: {}", app.mode.label()); - let _ = writeln!(out, "- Model: {}", app.model_display_label()); - if let Some(focus) = focus { - let _ = writeln!(out, "- Requested relay focus: {focus}"); - } - if let Some(quarry) = app.hunt.quarry.as_deref() { - let _ = writeln!(out, "- Hunt quarry: {quarry}"); - } - if let Some(budget) = app.hunt.token_budget { - let _ = writeln!(out, "- Hunt token budget: {budget}"); - } - if let Ok(todos) = app.todos.try_lock() { - let snapshot = todos.snapshot(); - if !snapshot.items.is_empty() { - let _ = writeln!( - out, - "\nWork checklist (primary progress surface, {}% complete):", - snapshot.completion_pct - ); - for item in snapshot.items { - let _ = writeln!( - out, - "- #{} [{}] {}", - item.id, - item.status.as_str(), - item.content - ); - } - } - } else { - let _ = writeln!( - out, - "\nWork checklist: unavailable because the checklist is busy." - ); - } - - if let Ok(plan) = app.plan_state.try_lock() { - let snapshot = plan.snapshot(); - if snapshot.explanation.is_some() || !snapshot.items.is_empty() { - let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); - if let Some(explanation) = snapshot.explanation.as_deref() { - let _ = writeln!(out, "- Explanation: {explanation}"); - } - for item in snapshot.items { - let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); - } - } - } else { - let _ = writeln!( - out, - "\nStrategy metadata: unavailable because plan state is busy." - ); + // Registry lookup. + if let Some(cmd_obj) = registry().get(command) { + return cmd_obj.execute(app, arg); } - let _ = writeln!( - out, - "\nBefore writing, inspect the current transcript context and any live tool evidence you need. Do not invent test results, file changes, blockers, or decisions." - ); - let _ = writeln!( - out, - "\nUse this compact structure:\n\ - # Session relay\n\ - \n\ - ## Goal\n\ - [the user's objective and any explicit constraints]\n\ - \n\ - ## Current work\n\ - [the active Work checklist item, progress, and what is mid-flight]\n\ - \n\ - ## Files and state\n\ - [changed files, important paths, sub-agents/RLM sessions, commands run]\n\ - \n\ - ## Decisions\n\ - [why key choices were made]\n\ - \n\ - ## Verification\n\ - [what passed, what failed, what was not run]\n\ - \n\ - ## Next action\n\ - [one concrete action for the next thread]" - ); - let _ = writeln!( - out, - "\nKeep it under about 900 words unless the session genuinely needs more. After writing, report the path and the single next action." - ); - out -} - -fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { - match status { - crate::tools::plan::StepStatus::Pending => "pending", - crate::tools::plan::StepStatus::InProgress => "in_progress", - crate::tools::plan::StepStatus::Completed => "completed", + // Skill fallback (lowest precedence). + if let Some(result) = groups::skills::skill::skill_impl::run_skill_by_name(app, command, arg) { + return result; } -} -fn parse_depth_prefixed_arg( - arg: Option<&str>, - default_depth: u32, -) -> Result<(u32, Option<&str>), String> { - let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { - return Ok((default_depth, None)); - }; - let mut parts = raw.splitn(2, char::is_whitespace); - let first = parts.next().unwrap_or_default(); - if first.chars().all(|ch| ch.is_ascii_digit()) { - let depth: u32 = first - .parse() - .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; - if depth > 3 { - return Err("Depth must be between 0 and 3".to_string()); - } - Ok((depth, parts.next().map(str::trim))) + let suggestions = suggest_command_names(command, 3); + if suggestions.is_empty() { + CommandResult::error(format!( + "Unknown command: /{command}. Type /help for available commands." + )) } else { - Ok((default_depth, Some(raw))) + let list = suggestions + .into_iter() + .map(|name| format!("/{name}")) + .collect::<Vec<_>>() + .join(", "); + CommandResult::error(format!( + "Unknown command: /{command}. Did you mean: {list}? Type /help for available commands." + )) } } -fn resolves_to_existing_file(app: &App, input: &str) -> bool { - let path = std::path::Path::new(input); - let candidate = if path.is_absolute() { - path.to_path_buf() - } else { - app.workspace.join(path) - }; - candidate.is_file() -} - -/// Get command info by name or alias -pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { - let name = name.strip_prefix('/').unwrap_or(name); - COMMANDS - .iter() - .find(|cmd| cmd.name == name || cmd.aliases.contains(&name)) -} - -/// Get all command names matching a prefix, including both built-in -/// static commands and user-defined commands, formatted as `/name`. -/// -/// `workspace` is used to also scan workspace-local command directories; -/// pass `None` when no workspace context is available. -#[allow(dead_code)] -pub fn all_command_names_matching( - prefix: &str, - workspace: Option<&std::path::Path>, -) -> Vec<String> { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - let mut result: Vec<String> = COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .map(|cmd| format!("/{}", cmd.name)) - .collect(); - - // Add user-defined commands - result.extend(user_commands::user_commands_matching(&prefix, workspace)); - - result.sort(); - result.dedup(); - result -} - -/// Get all commands matching a prefix (for autocomplete) -#[allow(dead_code)] -pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> { - let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - COMMANDS - .iter() - .filter(|cmd| { - cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) - }) - .collect() -} +// ── Suggestions ──────────────────────────────────────────────────────────── fn edit_distance(a: &str, b: &str) -> usize { if a == b { @@ -1021,6 +164,97 @@ fn edit_distance(a: &str, b: &str) -> usize { prev[b_chars.len()] } +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + App::new( + TuiOptions { + model: "deepseek-v4-pro".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: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }, + &Config::default(), + ) + } + + #[test] + fn registry_contains_commands() { + let cmds = registry().infos(); + assert!(!cmds.is_empty(), "registry should contain commands"); + assert!(cmds.iter().any(|c| c.name == "help")); + assert!(cmds.iter().any(|c| c.name == "clear")); + assert!(cmds.iter().any(|c| c.name == "config")); + } + + #[test] + fn execute_help_command_succeeds() { + let mut app = test_app(); + let result = execute("/help", &mut app); + assert!( + !result.is_error, + "help should succeed: {:?}", + result.message + ); + } + + #[test] + fn execute_unknown_command_returns_error() { + let mut app = test_app(); + let result = execute("/nonexistent", &mut app); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or("") + .contains("Unknown command") + ); + } + + #[test] + fn execute_without_slash_still_works() { + let mut app = test_app(); + let result = execute("help", &mut app); + assert!(!result.is_error); + } + + #[test] + fn execute_dispatches_by_alias() { + let mut app = test_app(); + let result = execute("/qingping", &mut app); + assert!(!result.is_error, "alias /qingping should dispatch to clear"); + } + + #[test] + fn unknown_command_suggests_similar() { + let mut app = test_app(); + let result = execute("/hel", &mut app); + let msg = result.message.as_deref().unwrap_or(""); + assert!(msg.contains("Did you mean")); + } +} + fn suggest_command_names(input: &str, limit: usize) -> Vec<String> { let query = input.trim().to_ascii_lowercase(); if query.is_empty() || limit == 0 { @@ -1028,9 +262,9 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> { } let mut scored: Vec<(u8, usize, String)> = Vec::new(); - for command in COMMANDS { + for info in registry().infos() { let mut best: Option<(u8, usize)> = None; - for candidate in std::iter::once(command.name).chain(command.aliases.iter().copied()) { + for candidate in std::iter::once(info.name).chain(info.aliases.iter().copied()) { let candidate = candidate.to_ascii_lowercase(); let prefix_match = candidate.starts_with(&query) || query.starts_with(&candidate); let contains_match = candidate.contains(&query) || query.contains(&candidate); @@ -1056,7 +290,7 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> { } if let Some((rank, distance)) = best { - scored.push((rank, distance, command.name.to_string())); + scored.push((rank, distance, info.name.to_string())); } } @@ -1071,492 +305,3 @@ fn suggest_command_names(input: &str, limit: usize) -> Vec<String> { .map(|(_, _, name)| name) .collect() } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{ApiProvider, Config}; - use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; - use crate::tools::todo::TodoStatus; - use crate::tui::app::{App, AppAction, TuiOptions}; - use std::ffi::OsString; - use std::path::{Path, PathBuf}; - use std::sync::MutexGuard; - use tempfile::tempdir; - - fn create_test_app() -> App { - let options = TuiOptions { - model: "deepseek-v4-pro".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: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn command_registry_contains_config_and_links_but_not_set_or_deepseek() { - assert!(COMMANDS.iter().any(|cmd| cmd.name == "config")); - assert!(COMMANDS.iter().any(|cmd| cmd.name == "links")); - assert!(COMMANDS.iter().any(|cmd| cmd.name == "memory")); - assert!(!COMMANDS.iter().any(|cmd| cmd.name == "set")); - assert!(!COMMANDS.iter().any(|cmd| cmd.name == "deepseek")); - } - - #[test] - fn links_command_has_dashboard_and_api_aliases() { - let links = COMMANDS - .iter() - .find(|cmd| cmd.name == "links") - .expect("links command should exist"); - assert_eq!(links.aliases, &["dashboard", "api", "lianjie"]); - } - - #[test] - fn rlm_slash_command_routes_to_persistent_tool_instruction() { - let mut app = create_test_app(); - let result = execute("/rlm 2 inspect this long corpus", &mut app); - assert!(!result.is_error); - assert!(result.message.as_deref().unwrap_or("").contains("depth 2")); - let Some(AppAction::SendMessage(message)) = result.action else { - panic!("expected SendMessage action"); - }; - assert!(message.contains("rlm_open")); - assert!(message.contains("rlm_configure")); - assert!(message.contains("sub_rlm_max_depth: 2")); - } - - #[test] - fn agent_slash_command_routes_to_persistent_tool_instruction() { - let mut app = create_test_app(); - let result = execute("/agent 0 inspect the parser", &mut app); - assert!(!result.is_error); - let Some(AppAction::SendMessage(message)) = result.action else { - panic!("expected SendMessage action"); - }; - assert!(message.contains("agent_open")); - assert!(message.contains("max_depth: 0")); - } - - #[test] - fn relay_slash_command_routes_to_session_relay_instruction() { - let mut app = create_test_app(); - app.hunt.quarry = Some("Unify the work surface".to_string()); - app.hunt.token_budget = Some(12_000); - { - let mut todos = app.todos.try_lock().expect("todo lock"); - todos.add("inspect workspace".to_string(), TodoStatus::Completed); - todos.add("patch relay command".to_string(), TodoStatus::InProgress); - } - { - let mut plan = app.plan_state.try_lock().expect("plan lock"); - plan.update(UpdatePlanArgs { - explanation: Some("RLM-style strategy".to_string()), - plan: vec![PlanItemArg { - step: "keep checklist primary".to_string(), - status: StepStatus::InProgress, - }], - }); - } - - let result = execute("/relay verify install", &mut app); - assert!(!result.is_error); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains(".deepseek/handoff.md") - ); - let Some(AppAction::SendMessage(message)) = result.action else { - panic!("expected SendMessage action"); - }; - assert!(message.contains("session relay")); - assert!(message.contains("接力")); - assert!(message.contains("Write or update `.deepseek/handoff.md`")); - assert!(message.contains("# Session relay")); - assert!(message.contains("Requested relay focus: verify install")); - assert!(message.contains("Hunt quarry: Unify the work surface")); - assert!(message.contains("Hunt token budget: 12000")); - assert!(message.contains("Work checklist (primary progress surface, 50% complete)")); - assert!(message.contains("#1 [completed] inspect workspace")); - assert!(message.contains("#2 [in_progress] patch relay command")); - assert!(message.contains("Optional strategy metadata from update_plan")); - assert!(message.contains("Explanation: RLM-style strategy")); - assert!(message.contains("[in_progress] keep checklist primary")); - } - - #[test] - fn relay_command_has_bilingual_aliases() { - let relay = COMMANDS - .iter() - .find(|cmd| cmd.name == "relay") - .expect("relay command should exist"); - assert_eq!(relay.aliases, &["batonpass", "接力"]); - assert!(relay.description_for(Locale::ZhHans).contains("接力")); - assert!(relay.description_for(Locale::ZhHant).contains("接力")); - - let mut app = create_test_app(); - let result = execute("/接力 next hand", &mut app); - assert!(!result.is_error); - let Some(AppAction::SendMessage(message)) = result.action else { - panic!("expected SendMessage action"); - }; - assert!(message.contains("Requested relay focus: next hand")); - } - - #[test] - fn command_registry_has_unique_names_and_aliases() { - let mut names = std::collections::BTreeSet::new(); - for command in COMMANDS { - assert!( - names.insert(command.name), - "duplicate command name /{}", - command.name - ); - } - - let mut aliases = std::collections::BTreeSet::new(); - for command in COMMANDS { - for alias in command.aliases { - assert!( - !names.contains(alias), - "alias /{alias} collides with a command name" - ); - assert!(aliases.insert(*alias), "duplicate command alias /{alias}"); - } - } - } - - #[test] - fn context_command_opens_inspector_and_keeps_ctx_alias() { - let context = COMMANDS - .iter() - .find(|cmd| cmd.name == "context") - .expect("context command should exist"); - assert_eq!(context.aliases, &["ctx"]); - assert!(context.description_for(Locale::En).contains("inspector")); - - let mut app = create_test_app(); - let result = execute("/ctx", &mut app); - assert!(matches!( - result.action, - Some(AppAction::OpenContextInspector) - )); - } - - #[test] - fn cache_inspect_dispatches_through_cache_command() { - let mut app = create_test_app(); - let result = execute("/cache inspect", &mut app); - let msg = result.message.expect("cache inspect should return text"); - assert!(msg.contains("Cache Inspect")); - assert!(msg.contains("Base static prefix hash:")); - assert!(msg.contains("Full request prefix hash:")); - assert!(result.action.is_none()); - } - - #[test] - fn cache_warmup_dispatches_action() { - let mut app = create_test_app(); - let result = execute("/cache warmup", &mut app); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::CacheWarmup))); - } - - #[test] - fn execute_config_opens_config_view_action() { - let mut app = create_test_app(); - let result = execute("/config", &mut app); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::OpenConfigView))); - } - - #[test] - fn execute_verbose_toggles_live_transcript_detail() { - let mut app = create_test_app(); - assert!(!app.verbose_transcript); - - let result = execute("/verbose on", &mut app); - assert!(!result.is_error); - assert!(app.verbose_transcript); - assert!(result.message.unwrap().contains("on")); - - let result = execute("/verbose off", &mut app); - assert!(!result.is_error); - assert!(!app.verbose_transcript); - assert!(result.message.unwrap().contains("off")); - } - - #[test] - fn execute_links_and_aliases_return_links_message() { - let mut app = create_test_app(); - for cmd in ["/links", "/dashboard", "/api", "/lianjie"] { - let result = execute(cmd, &mut app); - let msg = result.message.expect("links commands should return text"); - assert!(msg.contains("https://platform.deepseek.com")); - assert!(result.action.is_none()); - } - } - - #[test] - fn execute_workspace_alias_switches_workspace() { - let dir = tempdir().expect("temp dir"); - let mut app = create_test_app(); - let result = execute(&format!("/cwd {}", dir.path().display()), &mut app); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() - )); - } - - #[test] - fn removed_set_and_deepseek_commands_show_migration_hints() { - let mut app = create_test_app(); - let set_result = execute("/set model deepseek-v4-pro", &mut app); - let set_msg = set_result - .message - .expect("legacy command should return an error message"); - assert!(set_msg.contains("The /set command was retired")); - assert!(set_msg.contains("/config")); - assert!(set_msg.contains("/settings")); - assert!(set_result.action.is_none()); - - let deepseek_result = execute("/deepseek", &mut app); - let deepseek_msg = deepseek_result - .message - .expect("legacy command should return an error message"); - assert!(deepseek_msg.contains("The /deepseek command was renamed")); - assert!(deepseek_msg.contains("/links")); - assert!(deepseek_msg.contains("/dashboard")); - assert!(deepseek_msg.contains("/api")); - assert!(deepseek_result.action.is_none()); - } - - struct ConfigPathGuard { - previous: Option<OsString>, - _lock: MutexGuard<'static, ()>, - } - - impl ConfigPathGuard { - fn new(config_path: &Path) -> Self { - let lock = crate::test_support::lock_test_env(); - let previous = std::env::var_os("DEEPSEEK_CONFIG_PATH"); - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - std::env::set_var("DEEPSEEK_CONFIG_PATH", config_path); - } - Self { - previous, - _lock: lock, - } - } - } - - impl Drop for ConfigPathGuard { - fn drop(&mut self) { - // Safety: test-only environment mutation guarded by a global mutex. - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var("DEEPSEEK_CONFIG_PATH", previous); - } else { - std::env::remove_var("DEEPSEEK_CONFIG_PATH"); - } - } - } - } - - /// Build an App scoped to an isolated tempdir so dispatch-side-effects - /// (e.g. `/init` writing AGENTS.md, `/export` writing chat transcripts, - /// `/logout` clearing credentials) don't pollute the repo working tree or - /// the developer's real config when the smoke tests run. - fn create_isolated_test_app() -> (App, tempfile::TempDir, ConfigPathGuard) { - let tmpdir = tempfile::TempDir::new().expect("tempdir for smoke test"); - let workspace = tmpdir.path().to_path_buf(); - let config_path = workspace.join(".deepseek").join("config.toml"); - std::fs::create_dir_all(config_path.parent().expect("config parent")).expect("config dir"); - let guard = ConfigPathGuard::new(&config_path); - let options = TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: workspace.clone(), - config_path: Some(config_path), - config_profile: None, - allow_shell: false, - use_alt_screen: true, - use_mouse_capture: false, - use_bracketed_paste: true, - max_subagents: 1, - skills_dir: workspace.join("skills"), - memory_path: workspace.join("memory.md"), - notes_path: workspace.join("notes.txt"), - mcp_config_path: workspace.join("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - let app = App::new(options, &Config::default()); - (app, tmpdir, guard) - } - - /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. - /// A dispatch miss surfaces as the fall-through `Unknown command:` error - /// message in `execute`. This catches the case where a new command is - /// added to `COMMANDS` (so it shows up in `/help` and the palette) but - /// the matching arm in `execute` is forgotten — the user would type the - /// command, see it autocomplete, and then get an unhelpful "did you - /// mean" suggestion. Also catches panics in handlers because the test - /// runner unwinds the panic and reports the offending command. - /// `/save` and `/export` default their output paths to `cwd`-relative - /// filenames when no arg is supplied, which would scribble files into - /// `crates/tui/` when CI runs from there. Pass an explicit tempdir- - /// relative path for those two so the dispatch test stays sandboxed. - fn invocation_for(command_name: &str, alias_or_name: &str, tmpdir: &std::path::Path) -> String { - match command_name { - "save" => format!("/{alias_or_name} {}", tmpdir.join("session.json").display()), - "export" => format!("/{alias_or_name} {}", tmpdir.join("chat.md").display()), - _ => format!("/{alias_or_name}"), - } - } - - /// `/restore` is covered by its own dedicated tests in - /// `commands/restore.rs` that serialize on the global env mutex via - /// `scoped_home` (snapshot repo init shells out to git, which races - /// against parallel-running tests). Skip it here so this smoke test - /// stays parallel-safe. - fn skip_in_dispatch_smoke(name: &str) -> bool { - name == "restore" - } - - /// Smoke test: every entry in `COMMANDS` must dispatch to a real handler. - /// A dispatch miss surfaces as the fall-through `Unknown command:` error - /// message in `execute`. This catches the case where a new command is - /// added to `COMMANDS` (so it shows up in `/help` and the palette) but - /// the matching arm in `execute` is forgotten — the user would type the - /// command, see it autocomplete, and then get an unhelpful "did you - /// mean" suggestion. Also catches panics in handlers because the test - /// runner unwinds the panic and reports the offending command. - #[test] - fn every_registered_command_dispatches_to_a_handler() { - for command in COMMANDS { - if skip_in_dispatch_smoke(command.name) { - continue; - } - let (mut app, tmpdir, _guard) = create_isolated_test_app(); - let invocation = invocation_for(command.name, command.name, tmpdir.path()); - let result = execute(&invocation, &mut app); - if let Some(msg) = &result.message { - assert!( - !msg.contains("Unknown command"), - "/{} fell through to the unknown-command branch: {msg}", - command.name, - ); - } - } - } - - /// Same check, but for declared aliases — `/q` should not fall through - /// just because the registry lists it as an alias of `/exit`. - #[test] - fn every_command_alias_dispatches_to_a_handler() { - for command in COMMANDS { - if skip_in_dispatch_smoke(command.name) { - continue; - } - for alias in command.aliases { - let (mut app, tmpdir, _guard) = create_isolated_test_app(); - let invocation = invocation_for(command.name, alias, tmpdir.path()); - let result = execute(&invocation, &mut app); - if let Some(msg) = &result.message { - assert!( - !msg.contains("Unknown command"), - "/{alias} (alias of /{}) fell through to unknown: {msg}", - command.name, - ); - } - } - } - } - - #[test] - fn balance_command_has_own_help_text() { - let info = get_command_info("balance").expect("balance command should be registered"); - assert_eq!(info.description_id, MessageId::CmdBalanceDescription); - assert!( - info.description_for(Locale::En) - .contains("provider account balance") - ); - } - - #[test] - fn balance_command_reports_scaffold_without_claiming_dispatch() { - let mut app = create_test_app(); - app.api_provider = ApiProvider::Deepseek; - - let result = execute("/balance", &mut app); - let msg = result - .message - .expect("balance scaffold should explain current state"); - - assert!(!result.is_error); - assert!(msg.contains("DeepSeek")); - assert!(msg.contains("not wired")); - assert!(!msg.contains("sent")); - } - - #[test] - fn balance_command_reports_unsupported_provider_clearly() { - let mut app = create_test_app(); - app.api_provider = ApiProvider::Ollama; - - let result = execute("/balance", &mut app); - let msg = result - .message - .expect("unsupported providers should return a clear message"); - - assert!(!result.is_error); - assert!(msg.contains("Ollama")); - assert!(msg.contains("not supported")); - assert!(msg.contains("dashboard")); - } - - #[test] - fn unknown_command_suggests_nearest_match() { - let mut app = create_test_app(); - let result = execute("/modle", &mut app); - let msg = result - .message - .expect("unknown command should return an error message"); - assert!(msg.contains("Unknown command: /modle")); - assert!(msg.contains("Did you mean:")); - assert!(msg.contains("/model")); - } - - #[test] - fn unknown_command_without_close_match_keeps_help_guidance() { - let mut app = create_test_app(); - let result = execute("/zzzzzz", &mut app); - let msg = result - .message - .expect("unknown command should return an error message"); - assert!(msg.contains("Unknown command: /zzzzzz")); - assert!(msg.contains("Type /help for available commands.")); - } -} diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs deleted file mode 100644 index 098b00ebc..000000000 --- a/crates/tui/src/commands/session.rs +++ /dev/null @@ -1,1010 +0,0 @@ -//! Session commands: save, load, compact, export - -use std::fmt::Write; -use std::path::PathBuf; - -use crate::session_manager::{ - create_saved_session_with_id_and_mode, create_saved_session_with_mode, -}; -use crate::tui::app::{App, AppAction}; -use crate::tui::history::{HistoryCell, history_cells_from_message}; -use crate::tui::session_picker::SessionPickerView; - -use super::CommandResult; - -/// Save session to file. -/// -/// When an explicit path is given, the session is exported there -/// (user-visible explicit export). Without a path, v0.8.44 saves -/// into the managed session directory (`~/.codewhale/sessions` -/// or legacy `~/.deepseek/sessions`) so repo-local `session_*.json` -/// artifacts are no longer created by default. -pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { - let save_path = if let Some(p) = path { - PathBuf::from(p) - } else { - let dir = crate::session_manager::default_sessions_dir() - .unwrap_or_else(|_| app.workspace.clone()); - let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - dir.join(format!("session_{timestamp}.json")) - }; - - let messages = app.api_messages.clone(); - let mut session = create_saved_session_with_mode( - &messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - app.sync_cost_to_metadata(&mut session.metadata); - session.artifacts = app.session_artifacts.clone(); - - let sessions_dir = save_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf); - - match std::fs::create_dir_all(&sessions_dir) { - Ok(()) => { - let json = match serde_json::to_string_pretty(&session) { - Ok(j) => j, - Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")), - }; - match std::fs::write(&save_path, json) { - Ok(()) => { - app.current_session_id = Some(session.metadata.id.clone()); - CommandResult::message(format!( - "Session saved to {} (ID: {})", - save_path.display(), - crate::session_manager::truncate_id(&session.metadata.id) - )) - } - Err(e) => CommandResult::error(format!("Failed to save session: {e}")), - } - } - Err(e) => CommandResult::error(format!("Failed to create directory: {e}")), - } -} - -/// Fork the active conversation into a new saved sibling session and switch to it. -pub fn fork(app: &mut App) -> CommandResult { - if app.api_messages.is_empty() { - return CommandResult::error("Nothing to fork. Send or load a message first."); - } - - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(manager) => manager, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - - let parent_id = app - .current_session_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let mut parent = create_saved_session_with_id_and_mode( - parent_id, - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - app.sync_cost_to_metadata(&mut parent.metadata); - parent.artifacts = app.session_artifacts.clone(); - - if let Err(err) = manager.save_session(&parent) { - return CommandResult::error(format!("Failed to save parent session: {err}")); - } - - let mut forked = create_saved_session_with_mode( - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - forked.metadata.copy_cost_from(&parent.metadata); - forked.metadata.mark_forked_from(&parent.metadata); - - if let Err(err) = manager.save_session(&forked) { - return CommandResult::error(format!("Failed to save forked session: {err}")); - } - - app.current_session_id = Some(forked.metadata.id.clone()); - let fork_id = forked.metadata.id.clone(); - let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string(); - let fork_label = crate::session_manager::truncate_id(&fork_id).to_string(); - - CommandResult::with_message_and_action( - format!("Forked session {parent_label} -> {fork_label}"), - AppAction::SyncSession { - session_id: Some(fork_id), - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) -} - -/// Start a fresh saved session from the current TUI state. -pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { - let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { - None => false, - Some("--force" | "force") => true, - Some(other) => { - return CommandResult::error(format!( - "Usage: /new [--force]\n\nUnknown argument: {other}" - )); - } - }; - - if !force { - let blockers = new_session_blockers(app); - if !blockers.is_empty() { - return CommandResult::error(format!( - "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", - blockers.join(", ") - )); - } - } - - let new_id = uuid::Uuid::new_v4().to_string(); - super::core::reset_conversation_state(app); - app.clear_input(); - app.session_artifacts.clear(); - app.session_context_references.clear(); - app.tool_evidence.clear(); - app.current_session_id = Some(new_id.clone()); - app.session_title = Some("New Session".to_string()); - app.scroll_to_bottom(); - - CommandResult::with_message_and_action( - format!( - "Started new session {} (New Session). Previous sessions remain available via /resume.", - crate::session_manager::truncate_id(&new_id) - ), - AppAction::SyncSession { - session_id: Some(new_id), - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) -} - -fn new_session_blockers(app: &App) -> Vec<&'static str> { - let mut blockers = Vec::new(); - if !app.input.trim().is_empty() { - blockers.push("the composer has unsent text"); - } - if !app.queued_messages.is_empty() || app.queued_draft.is_some() { - blockers.push("queued messages are pending"); - } - if app.is_loading || app.runtime_turn_status.as_deref() == Some("in_progress") { - blockers.push("a turn is in progress"); - } - if app.is_compacting { - blockers.push("context compaction is running"); - } - if app.task_panel.iter().any(|task| task.status == "running") { - blockers.push("background tasks are running"); - } - blockers -} - -/// Load session from file -pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { - let load_path = if let Some(p) = path { - if p.contains('/') || p.contains('\\') { - PathBuf::from(p) - } else { - app.workspace.join(p) - } - } else { - return CommandResult::error("Usage: /load <path>"); - }; - - let content = match std::fs::read_to_string(&load_path) { - Ok(c) => c, - Err(e) => { - return CommandResult::error(format!("Failed to read session file: {e}")); - } - }; - - let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { - Ok(s) => s, - Err(e) => { - return CommandResult::error(format!("Failed to parse session file: {e}")); - } - }; - - app.api_messages.clone_from(&session.messages); - app.clear_history(); - let cells_to_add: Vec<_> = app - .api_messages - .iter() - .flat_map(history_cells_from_message) - .collect(); - app.extend_history(cells_to_add); - app.mark_history_updated(); - app.viewport.transcript_selection.clear(); - app.set_model_selection(session.metadata.model.clone()); - app.update_model_compaction_budget(); - app.workspace.clone_from(&session.metadata.workspace); - app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); - app.session.total_conversation_tokens = app.session.total_tokens; - // Accumulated token breakdown is per-runtime-session; zero on load. - app.session.reset_token_breakdown(); - app.session.session_cost = 0.0; - app.session.session_cost_cny = 0.0; - app.session.subagent_cost = 0.0; - app.session.subagent_cost_cny = 0.0; - app.session.subagent_cost_event_seqs.clear(); - app.session.displayed_cost_high_water = 0.0; - app.session.displayed_cost_high_water_cny = 0.0; - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - app.session.last_prompt_cache_hit_tokens = None; - app.session.last_prompt_cache_miss_tokens = None; - app.session.last_reasoning_replay_tokens = None; - app.session.turn_cache_history.clear(); - app.current_session_id = Some(session.metadata.id.clone()); - app.session_artifacts = session.artifacts.clone(); - if let Some(sp) = session.system_prompt { - app.system_prompt = Some(crate::models::SystemPrompt::Text(sp)); - } - app.scroll_to_bottom(); - - CommandResult::with_message_and_action( - format!( - "Session loaded from {} (ID: {}, {} messages)", - load_path.display(), - crate::session_manager::truncate_id(&session.metadata.id), - session.metadata.message_count - ), - crate::tui::app::AppAction::SyncSession { - session_id: app.current_session_id.clone(), - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) -} - -/// Trigger context compaction -pub fn compact(_app: &mut App) -> CommandResult { - // Trigger immediate compaction via engine - CommandResult::with_message_and_action( - "Context compaction triggered...".to_string(), - AppAction::CompactContext, - ) -} - -/// Trigger agent-driven context purging. -pub fn purge(_app: &mut App) -> CommandResult { - CommandResult::with_message_and_action( - "Agent context purge triggered...".to_string(), - AppAction::PurgeContext, - ) -} - -/// Export conversation to markdown -pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { - let export_path = path.map_or_else( - || { - let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); - PathBuf::from(format!("chat_export_{timestamp}.md")) - }, - PathBuf::from, - ); - - let mut content = String::new(); - content.push_str("# Chat Export\n\n"); - let _ = write!( - content, - "**Model:** {}\n**Workspace:** {}\n**Date:** {}\n\n---\n\n", - app.model, - app.workspace.display(), - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ); - - for cell in &app.history { - let (role, body) = match cell { - HistoryCell::User { content } => ("**You:**", content.clone()), - HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()), - HistoryCell::System { content } => ("*System:*", content.clone()), - HistoryCell::Error { message, severity } => match severity { - crate::error_taxonomy::ErrorSeverity::Warning => ("**Warning:**", message.clone()), - crate::error_taxonomy::ErrorSeverity::Info => ("*Info:*", message.clone()), - _ => ("**Error:**", message.clone()), - }, - HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), - HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), - HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)), - HistoryCell::ArchivedContext { - level, - range, - summary, - .. - } => ( - "**Archived Context:**", - format!("L{level} [{range}]: {summary}"), - ), - }; - - let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim()); - } - - match std::fs::write(&export_path, content) { - Ok(()) => CommandResult::message(format!("Exported to {}", export_path.display())), - Err(e) => CommandResult::error(format!("Failed to export: {e}")), - } -} - -/// Open the session picker UI, or run a sub-action like -/// `prune <days>` for housekeeping (#406 phase-1.5). -pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { - let trimmed = arg.unwrap_or("").trim(); - if trimmed.is_empty() { - app.view_stack.push(SessionPickerView::new(&app.workspace)); - return CommandResult::ok(); - } - - let mut parts = trimmed.split_whitespace(); - let action = parts.next().unwrap_or("").to_ascii_lowercase(); - match action.as_str() { - "prune" => prune(app, parts.next()), - "show" | "list" | "picker" => { - app.view_stack.push(SessionPickerView::new(&app.workspace)); - CommandResult::ok() - } - _ => CommandResult::error(format!( - "unknown subcommand `{action}`. usage: /sessions [show|prune <days>]" - )), - } -} - -/// Prune persisted sessions older than `<days>` from -/// `~/.deepseek/sessions/`. Wraps -/// [`crate::session_manager::SessionManager::prune_sessions_older_than`] -/// so users can run a safe cleanup without leaving the TUI. Skips -/// the checkpoint subdirectory (the helper guarantees that already). -fn prune(_app: &mut App, days_arg: Option<&str>) -> CommandResult { - let days_str = match days_arg { - Some(s) => s, - None => { - return CommandResult::error( - "usage: /sessions prune <days> (e.g. `/sessions prune 30` to drop sessions older than 30 days)", - ); - } - }; - let days: u64 = match days_str.parse() { - Ok(n) if n > 0 => n, - _ => { - return CommandResult::error(format!( - "expected a positive integer number of days, got `{days_str}`" - )); - } - }; - - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(m) => m, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - - let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60)); - match manager.prune_sessions_older_than(max_age) { - Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")), - Ok(n) => CommandResult::message(format!( - "pruned {n} session{} older than {days}d", - if n == 1 { "" } else { "s" } - )), - Err(err) => CommandResult::error(format!("prune failed: {err}")), - } -} - -fn render_tool_cell(tool: &crate::tui::history::ToolCell, width: u16) -> String { - tool.lines(width) - .into_iter() - .map(line_to_string) - .collect::<Vec<_>>() - .join("\n") -} - -fn render_subagent_cell(cell: &crate::tui::history::SubAgentCell, width: u16) -> String { - cell.lines(width) - .into_iter() - .map(line_to_string) - .collect::<Vec<_>>() - .join("\n") -} - -fn line_to_string(line: ratatui::text::Line<'static>) -> String { - line.spans - .into_iter() - .map(|span| span.content.to_string()) - .collect::<String>() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{Config, DEFAULT_TEXT_MODEL}; - use crate::test_support::EnvVarGuard; - use crate::tui::app::{App, ReasoningEffort, TuiOptions, TurnCacheRecord}; - use std::time::Instant; - use tempfile::TempDir; - - fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { - let options = TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: tmpdir.path().to_path_buf(), - 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: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - App::new(options, &Config::default()) - } - - #[test] - fn test_save_creates_file_and_sets_session_id() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let save_path = tmpdir.path().join("test_session.json"); - - let result = save(&mut app, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Session saved to")); - assert!(msg.contains("ID:")); - assert!(app.current_session_id.is_some()); - assert!(save_path.exists()); - } - - #[test] - fn save_preserves_artifact_registry() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let save_path = tmpdir.path().join("artifact_session.json"); - app.session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_call_big".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "artifact-session".to_string(), - tool_call_id: "call-big".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 512_000, - preview: "cargo test output".to_string(), - storage_path: tmpdir.path().join("call-big.txt"), - }); - - let result = save(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - let saved: crate::session_manager::SavedSession = - serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); - assert_eq!(saved.artifacts, app.session_artifacts); - } - - #[test] - fn fork_saves_parent_and_switches_to_child_session() { - let tmpdir = TempDir::new().unwrap(); - let _lock = crate::test_support::lock_test_env(); - let home = tmpdir.path().join("home"); - std::fs::create_dir_all(&home).unwrap(); - let home_guard = EnvVarGuard::set("HOME", &home); - let previous_home = home_guard.previous(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("parent-session".to_string()); - app.api_messages.push(crate::models::Message { - role: "user".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: "try another path".to_string(), - cache_control: None, - }], - }); - - let result = fork(&mut app); - - assert!(!result.is_error, "{:?}", result.message); - let new_id = app.current_session_id.clone().expect("fork session id"); - assert_ne!(new_id, "parent-session"); - assert!(result.message.as_deref().unwrap_or("").contains("Forked")); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - - let manager = crate::session_manager::SessionManager::default_location().unwrap(); - let parent = manager - .load_session("parent-session") - .expect("parent saved"); - let child = manager.load_session(&new_id).expect("child saved"); - assert_eq!(parent.messages.len(), 1); - assert_eq!( - child.metadata.parent_session_id.as_deref(), - Some("parent-session") - ); - assert_eq!(child.metadata.forked_from_message_count, Some(1)); - drop(home_guard); - assert_eq!(std::env::var_os("HOME"), previous_home); - } - - #[test] - fn new_session_from_resumed_state_creates_distinct_empty_session() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.session_title = Some("Old Session".to_string()); - app.api_messages.push(crate::models::Message { - role: "user".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: "continue this thread".to_string(), - cache_control: None, - }], - }); - app.add_message(HistoryCell::System { - content: "old transcript".to_string(), - }); - app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); - app.session.total_tokens = 123; - app.session.session_cost = 1.25; - - let result = new_session(&mut app, None); - - assert!(!result.is_error, "{:?}", result.message); - let new_id = app.current_session_id.clone().expect("new session id"); - assert_ne!(new_id, "old-session"); - assert_eq!(app.session_title.as_deref(), Some("New Session")); - assert!(app.api_messages.is_empty()); - assert!(app.history.is_empty()); - assert!(app.system_prompt.is_none()); - assert_eq!(app.session.total_tokens, 0); - assert_eq!(app.session.session_cost, 0.0); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("/resume") - ); - match result.action { - Some(AppAction::SyncSession { - session_id, - messages, - system_prompt, - .. - }) => { - assert_eq!(session_id.as_deref(), Some(new_id.as_str())); - assert!(messages.is_empty()); - assert!(system_prompt.is_none()); - } - other => panic!("expected SyncSession action, got {other:?}"), - } - } - - #[test] - fn new_session_blocks_unsent_input_without_force() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.input = "draft text".to_string(); - - let result = new_session(&mut app, None); - - assert!(result.is_error); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert_eq!(app.input, "draft text"); - assert!(result.action.is_none()); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("/new --force") - ); - } - - #[test] - fn new_session_force_discards_unsent_input() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.input = "draft text".to_string(); - - let result = new_session(&mut app, Some("--force")); - - assert!(!result.is_error, "{:?}", result.message); - assert_ne!(app.current_session_id.as_deref(), Some("old-session")); - assert!(app.input.is_empty()); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - } - - #[test] - fn new_session_blocks_in_flight_turn_without_force() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.is_loading = true; - - let result = new_session(&mut app, None); - - assert!(result.is_error); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert!(result.action.is_none()); - } - - #[test] - fn test_save_with_default_path_uses_managed_sessions_dir() { - let tmpdir = TempDir::new().unwrap(); - let _lock = crate::test_support::lock_test_env(); - // Set CODEWHALE_HOME so the managed sessions directory lands inside the - // temp dir rather than the real user home. Pre-create the directory so - // resolve_state_dir picks it up instead of falling back to legacy. - let home = tmpdir.path().join("home"); - let sessions_dir = home.join("sessions"); - std::fs::create_dir_all(&sessions_dir).unwrap(); - let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); - let previous_codewhale_home = codewhale_home.previous(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = save(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - // Give it a moment to ensure file is written - std::thread::sleep(std::time::Duration::from_millis(10)); - let entries: Vec<_> = if sessions_dir.exists() { - std::fs::read_dir(&sessions_dir) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("session_")) - .collect() - } else { - Vec::new() - }; - drop(codewhale_home); - // Session should be saved to the managed dir, not the workspace root. - assert!( - !entries.is_empty(), - "expected session file in {sessions_dir:?}, got none; msg: {msg}" - ); - assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home); - } - - #[test] - fn test_save_serialization_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - // This should work normally since SavedSession is serializable - // Testing error path would require mocking, which is complex - let save_path = tmpdir.path().join("test.json"); - let result = save(&mut app, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - } - - #[test] - fn test_load_without_path_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = load(&mut app, None); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Usage: /load")); - } - - #[test] - fn test_load_nonexistent_file_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = load(&mut app, Some("nonexistent.json")); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Failed to read")); - } - - #[test] - fn test_load_invalid_json_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let bad_file = tmpdir.path().join("bad.json"); - std::fs::write(&bad_file, "not valid json").unwrap(); - let result = load(&mut app, Some(bad_file.to_str().unwrap())); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Failed to parse")); - } - - #[test] - fn test_load_valid_session_restores_state() { - let tmpdir = TempDir::new().unwrap(); - let mut app1 = create_test_app_with_tmpdir(&tmpdir); - // Set up some state to save - app1.api_messages.push(crate::models::Message { - role: "user".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: "Hello".to_string(), - cache_control: None, - }], - }); - app1.session.total_tokens = 500; - let save_path = tmpdir.path().join("test.json"); - save(&mut app1, Some(save_path.to_str().unwrap())); - - // Create new app and load - let mut app2 = create_test_app_with_tmpdir(&tmpdir); - let result = load(&mut app2, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Session loaded from")); - assert!(msg.contains("ID:")); - assert!(msg.contains("messages")); - assert_eq!(app2.api_messages.len(), 1); - assert_eq!(app2.session.total_tokens, 500); - assert!(app2.current_session_id.is_some()); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - } - - #[test] - fn load_auto_model_session_restores_auto_mode() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app.set_model_selection("auto".to_string()); - saved_app.last_effective_model = Some("deepseek-v4-flash".to_string()); - saved_app.last_effective_reasoning_effort = Some(ReasoningEffort::Low); - let save_path = tmpdir.path().join("auto_model.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.set_model_selection("deepseek-v4-flash".to_string()); - app.reasoning_effort = ReasoningEffort::High; - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - assert!(app.auto_model); - assert_eq!(app.model, "auto"); - assert_eq!(app.model_selection_for_persistence(), "auto"); - assert_eq!(app.last_effective_model, None); - assert_eq!(app.last_effective_reasoning_effort, None); - assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); - assert_eq!(app.effective_model_for_budget(), DEFAULT_TEXT_MODEL); - } - - #[test] - fn load_restores_artifact_registry() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app - .session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_call_big".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "artifact-session".to_string(), - tool_call_id: "call-big".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 128, - preview: "checking crate".to_string(), - storage_path: tmpdir.path().join("call-big.txt"), - }); - let save_path = tmpdir.path().join("artifact_load.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_stale".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "stale-session".to_string(), - tool_call_id: "stale".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 1, - preview: "stale".to_string(), - storage_path: tmpdir.path().join("stale.txt"), - }); - - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - assert_eq!(app.session_artifacts, saved_app.session_artifacts); - } - - #[test] - fn load_resets_cache_history_and_cost() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app.api_messages.push(crate::models::Message { - role: "user".to_string(), - content: vec![crate::models::ContentBlock::Text { - text: "checkpoint".to_string(), - cache_control: None, - }], - }); - saved_app.session.total_tokens = 500; - let save_path = tmpdir.path().join("checkpoint.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.session.session_cost = 1.25; - app.session.session_cost_cny = 9.13; - app.session.subagent_cost = 0.75; - app.session.subagent_cost_cny = 5.48; - app.session.subagent_cost_event_seqs.insert(42); - app.session.displayed_cost_high_water = 2.0; - app.session.displayed_cost_high_water_cny = 14.61; - app.session.last_prompt_tokens = Some(120); - app.session.last_completion_tokens = Some(35); - app.session.last_prompt_cache_hit_tokens = Some(80); - app.session.last_prompt_cache_miss_tokens = Some(40); - app.session.last_reasoning_replay_tokens = Some(12); - app.push_turn_cache_record(TurnCacheRecord { - input_tokens: 120, - output_tokens: 35, - cache_hit_tokens: Some(80), - cache_miss_tokens: Some(40), - reasoning_replay_tokens: Some(12), - recorded_at: Instant::now(), - }); - - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert!(result.message.is_some()); - assert_eq!(app.session.total_tokens, 500); - assert_eq!(app.session.total_conversation_tokens, 500); - assert_eq!(app.session.session_cost, 0.0); - assert_eq!(app.session.session_cost_cny, 0.0); - assert_eq!(app.session.subagent_cost, 0.0); - assert_eq!(app.session.subagent_cost_cny, 0.0); - assert!(app.session.subagent_cost_event_seqs.is_empty()); - assert_eq!(app.session.displayed_cost_high_water, 0.0); - assert_eq!(app.session.displayed_cost_high_water_cny, 0.0); - assert_eq!(app.session.last_prompt_tokens, None); - assert_eq!(app.session.last_completion_tokens, None); - assert_eq!(app.session.last_prompt_cache_hit_tokens, None); - assert_eq!(app.session.last_prompt_cache_miss_tokens, None); - assert_eq!(app.session.last_reasoning_replay_tokens, None); - assert!(app.session.turn_cache_history.is_empty()); - } - - #[test] - fn test_compact_toggles_state() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - - let result = compact(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("compaction") || msg.contains("Compact")); - assert!(matches!(result.action, Some(AppAction::CompactContext))); - } - - #[test] - fn test_export_crees_markdown_file() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.history.push(HistoryCell::User { - content: "Hello".to_string(), - }); - app.history.push(HistoryCell::Assistant { - content: "Hi there".to_string(), - streaming: false, - }); - - let export_path = tmpdir.path().join("export.md"); - let result = export(&mut app, Some(export_path.to_str().unwrap())); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Exported to")); - assert!(export_path.exists()); - - let content = std::fs::read_to_string(&export_path).unwrap(); - assert!(content.contains("# Chat Export")); - assert!(content.contains("**Model:**")); - assert!(content.contains("**You:**")); - assert!(content.contains("**Assistant:**")); - } - - #[test] - fn test_export_with_default_path() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = export(&mut app, None); - assert!(result.message.is_some()); - // Should create file with timestamp name in current dir - let entries: Vec<_> = std::fs::read_dir(".") - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_")) - .collect(); - // Clean up - for entry in &entries { - let _ = std::fs::remove_file(entry.path()); - } - assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to")); - } - - #[test] - fn test_sessions_pushes_picker_view() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let initial_kind = app.view_stack.top_kind(); - - let result = sessions(&mut app, None); - assert_eq!(result.message, None); - assert!(result.action.is_none()); - // View should have changed (session picker should be on top) - assert_ne!(app.view_stack.top_kind(), initial_kind); - } - - #[test] - fn test_sessions_show_subcommand_pushes_picker_view() { - // `/sessions show` and `/sessions list` are explicit aliases - // for the no-arg picker form. Verify they don't fall through - // to the prune branch. - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let initial_kind = app.view_stack.top_kind(); - let result = sessions(&mut app, Some("show")); - assert_eq!(result.message, None); - assert_ne!(app.view_stack.top_kind(), initial_kind); - } - - #[test] - fn test_sessions_prune_requires_days_argument() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = sessions(&mut app, Some("prune")); - assert!(result.is_error); - assert!( - result.message.as_deref().unwrap_or("").contains("usage"), - "expected usage hint: {:?}", - result.message - ); - } - - #[test] - fn test_sessions_prune_rejects_non_positive_days() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - for bad in ["0", "-3", "abc", "3.14"] { - let result = sessions(&mut app, Some(&format!("prune {bad}"))); - assert!(result.is_error, "expected error for `{bad}`"); - } - } - - #[test] - fn test_sessions_unknown_subcommand_errors() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = sessions(&mut app, Some("teleport")); - assert!(result.is_error); - assert!( - result - .message - .as_deref() - .unwrap_or("") - .contains("unknown subcommand"), - "expected unknown-subcommand error: {:?}", - result.message - ); - } -} diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs deleted file mode 100644 index e852d030a..000000000 --- a/crates/tui/src/commands/skills.rs +++ /dev/null @@ -1,993 +0,0 @@ -//! Skills commands: skills, skill - -use std::fmt::Write; - -use crate::network_policy::NetworkPolicy; -use crate::skills::SkillRegistry; -use crate::skills::install::{ - self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource, - RegistryFetchResult, SkillSyncOutcome, SyncResult, UpdateResult, -}; -use crate::tui::app::App; -use crate::tui::history::HistoryCell; - -use super::CommandResult; - -fn discover_visible_skills(app: &App) -> SkillRegistry { - crate::skills::discover_for_workspace_and_dir(&app.workspace, &app.skills_dir) -} - -fn render_skill_warnings(registry: &SkillRegistry) -> String { - if registry.warnings().is_empty() { - return String::new(); - } - - let mut out = String::new(); - let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len()); - for warning in registry.warnings() { - let _ = writeln!(out, " - {warning}"); - } - out -} - -/// List all available skills. Pass `--remote` (or `remote`) to fetch the -/// curated registry instead of scanning the local skills directory. -/// Pass `sync` to pull the registry index and download all skills to the -/// local cache (`~/.codewhale/cache/skills/`). -pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult { - let mut prefix: Option<String> = None; - if let Some(arg) = arg { - let trimmed = arg.trim(); - if trimmed == "--remote" || trimmed == "remote" { - return list_remote_skills(app); - } - if trimmed == "sync" || trimmed == "--sync" { - return sync_skills(app); - } - if !trimmed.is_empty() { - // Anything else is treated as a name-prefix filter (#1318). - // Reject obviously malformed args (whitespace inside the - // prefix, leading dash) so future flag additions don't - // collide with skill names. Skill names that start with - // `-` aren't allowed by the loader so this is safe. - if trimmed.starts_with('-') || trimmed.split_whitespace().count() > 1 { - return CommandResult::error("Usage: /skills [--remote|sync|<name-prefix>]"); - } - prefix = Some(trimmed.to_ascii_lowercase()); - } - } - let skills_dir = app.skills_dir.clone(); - let registry = discover_visible_skills(app); - let warnings = render_skill_warnings(®istry); - - if registry.is_empty() { - let msg = format!( - "No skills found.\n\n\ - Skills location: {}\n\n\ - To add skills, create directories with SKILL.md files:\n \ - {}/my-skill/SKILL.md\n\n\ - Format:\n \ - ---\n \ - name: my-skill\n \ - description: What this skill does\n \ - allowed-tools: read_file, list_dir\n \ - ---\n\n \ - <instructions here>{warnings}", - skills_dir.display(), - skills_dir.display() - ); - return CommandResult::message(msg); - } - - let filtered: Vec<&crate::skills::Skill> = if let Some(p) = prefix.as_deref() { - registry - .list() - .iter() - .filter(|s| s.name.to_ascii_lowercase().starts_with(p)) - .collect() - } else { - registry.list().iter().collect() - }; - - if filtered.is_empty() { - // The user typed a prefix that matched nothing. Surface what - // they typed plus the full count so they can decide whether - // to adjust the prefix or run `/skills` for the whole list. - let p = prefix.as_deref().unwrap_or(""); - return CommandResult::message(format!( - "No skills match prefix `{p}` (out of {} available).\n\nRun /skills to see them all.{warnings}", - registry.len() - )); - } - - let mut output = if let Some(p) = prefix.as_deref() { - format!( - "Available skills matching `{p}` ({} of {}):\n", - filtered.len(), - registry.len() - ) - } else { - format!("Available skills ({}):\n", registry.len()) - }; - output.push_str("─────────────────────────────\n"); - - if prefix.is_some() { - // Filtered view: keep the flat list — the user already narrowed. - for (idx, skill) in filtered.iter().enumerate() { - if idx > 0 { - output.push('\n'); - } - let _ = writeln!(output, " /{} - {}", skill.name, skill.description); - } - } else { - // Unfiltered view: partition into user-created and built-in so a - // workspace skill at the top of the list isn't pushed off-screen - // by 10+ bundled descriptions. User skills always render with - // their full description; bundled skills render compactly when - // numerous so the whole menu fits in a typical terminal viewport. - let (user_skills, bundled_skills): ( - Vec<&&crate::skills::Skill>, - Vec<&&crate::skills::Skill>, - ) = filtered - .iter() - .partition(|s| !crate::skills::is_bundled_skill_name(&s.name)); - - if !user_skills.is_empty() { - let _ = writeln!(output, "Your skills ({}):", user_skills.len()); - for skill in &user_skills { - let _ = writeln!(output, " /{} - {}", skill.name, skill.description); - } - if !bundled_skills.is_empty() { - output.push('\n'); - } - } - - if !bundled_skills.is_empty() { - let _ = writeln!(output, "Built-in skills ({}):", bundled_skills.len()); - // When there are user skills to surface, keep built-ins compact - // (single-line names list) so they never crowd the viewport. - // When there are no user skills, render full descriptions — - // there is nothing else competing for space and the user is - // likely getting their first look at the catalog. - if user_skills.is_empty() { - for skill in &bundled_skills { - let _ = writeln!(output, " /{} - {}", skill.name, skill.description); - } - } else { - let names: Vec<String> = bundled_skills - .iter() - .map(|s| format!("/{}", s.name)) - .collect(); - output.push_str(" "); - output.push_str(&names.join(", ")); - output.push('\n'); - output.push_str(" (run /skills <name> for details on a built-in)\n"); - } - } - } - - let _ = write!( - output, - "\nUse /skill <name> to run a skill\nSkills location: {}{}", - skills_dir.display(), - warnings - ); - - CommandResult::message(output) -} - -/// Run a specific skill — activates skill for next user message, or -/// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`). -/// Try to run a skill by exact name (used for unified slash-command namespace, #435). -/// Returns None when no skill with that name exists, so the caller can try other sources. -pub fn run_skill_by_name(app: &mut App, name: &str, _arg: Option<&str>) -> Option<CommandResult> { - let registry = discover_visible_skills(app); - if registry.get(name).is_some() { - Some(activate_skill(app, name)) - } else { - None - } -} - -pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult { - let raw = match name { - Some(n) => n.trim(), - None => { - return CommandResult::error( - "Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>", - ); - } - }; - - // Sub-command dispatch happens before the activation path so users can't - // accidentally activate a skill literally named "install". - let mut iter = raw.splitn(2, char::is_whitespace); - let head = iter.next().unwrap_or("").trim(); - let rest = iter.next().unwrap_or("").trim(); - match head { - "install" => return install_skill(app, rest), - "update" => return update_skill(app, rest), - "uninstall" => return uninstall_skill(app, rest), - "trust" => return trust_skill(app, rest), - _ => {} - } - - activate_skill(app, raw) -} - -fn activate_skill(app: &mut App, name: &str) -> CommandResult { - // `/skill new` is a friendly alias for `/skill skill-creator`. - let name = if name == "new" { "skill-creator" } else { name }; - - let registry = discover_visible_skills(app); - - if let Some(skill) = registry.get(name) { - let instruction = format!( - "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.", - skill.name, skill.body - ); - - app.add_message(HistoryCell::System { - content: format!("Activated skill: {}\n\n{}", skill.name, skill.description), - }); - - app.active_skill = Some(instruction); - - CommandResult::message(format!( - "Skill '{}' activated.\n\nDescription: {}\n\nType your request and the skill instructions will be applied.", - skill.name, skill.description - )) - } else { - let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect(); - let warnings = render_skill_warnings(®istry); - - if available.is_empty() { - CommandResult::error(format!( - "Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}" - )) - } else { - CommandResult::error(format!( - "Skill '{}' not found.\n\nAvailable skills: {}{}", - name, - available.join(", "), - warnings - )) - } - } -} - -// ─── /skill install ──────────────────────────────────────────────────────── - -fn install_skill(app: &mut App, spec: &str) -> CommandResult { - if spec.is_empty() { - return CommandResult::error( - "Usage: /skill install <github:owner/repo|https://…|<registry-name>>", - ); - } - let source = match InstallSource::parse(spec) { - Ok(s) => s, - Err(err) => return CommandResult::error(format!("Invalid install source: {err}")), - }; - let skills_dir = app.skills_dir.clone(); - let (network, max_size, registry_url) = installer_settings(app); - - let outcome = run_async(async move { - install::install_with_registry( - source, - &skills_dir, - max_size, - &network, - false, - ®istry_url, - ) - .await - }); - - match outcome { - Ok(InstallOutcome::Installed(installed)) => { - app.refresh_skill_cache(); - let path_str = path_or_default(&installed.path); - CommandResult::message(format!( - "Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.", - installed.name, spec, path_str - )) - } - Ok(InstallOutcome::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(InstallOutcome::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) - } - Err(err) => CommandResult::error(format!("Install failed: {err:#}")), - } -} - -// ─── /skill update ───────────────────────────────────────────────────────── - -fn update_skill(app: &mut App, name: &str) -> CommandResult { - if name.is_empty() { - return CommandResult::error("Usage: /skill update <name>"); - } - let skills_dir = app.skills_dir.clone(); - let (network, max_size, registry_url) = installer_settings(app); - let owned_name = name.to_string(); - let outcome = run_async(async move { - install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url) - .await - }); - - match outcome { - Ok(UpdateResult::NoChange) => { - CommandResult::message(format!("Skill '{name}': no upstream change.")) - } - Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!( - "Skill '{}' updated. Location: {}", - installed.name, - path_or_default(&installed.path) - )), - Ok(UpdateResult::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(UpdateResult::NetworkDenied(host)) => { - CommandResult::error(network_denied_message(&host)) - } - Err(err) => CommandResult::error(format!("Update failed: {err:#}")), - } -} - -// ─── /skill uninstall ────────────────────────────────────────────────────── - -fn uninstall_skill(app: &mut App, name: &str) -> CommandResult { - if name.is_empty() { - return CommandResult::error("Usage: /skill uninstall <name>"); - } - match install::uninstall(name, &app.skills_dir) { - Ok(()) => { - app.refresh_skill_cache(); - CommandResult::message(format!("Removed skill '{name}'.")) - } - Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")), - } -} - -// ─── /skill trust ────────────────────────────────────────────────────────── - -fn trust_skill(app: &mut App, name: &str) -> CommandResult { - if name.is_empty() { - return CommandResult::error("Usage: /skill trust <name>"); - } - match install::trust(name, &app.skills_dir) { - Ok(()) => CommandResult::message(format!( - "Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/." - )), - Err(err) => CommandResult::error(format!("Trust failed: {err:#}")), - } -} - -// ─── /skills --remote ────────────────────────────────────────────────────── - -/// List skills available in the configured curated registry. -pub fn list_remote_skills(app: &mut App) -> CommandResult { - let (network, _max_size, registry_url) = installer_settings(app); - let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await }); - match registry { - Ok(RegistryFetchResult::Loaded(doc)) => { - if doc.skills.is_empty() { - return CommandResult::message("Registry is empty."); - } - let mut out = format!("Available remote skills ({}):\n", doc.skills.len()); - out.push_str("─────────────────────────────\n"); - for (name, entry) in &doc.skills { - let _ = writeln!( - out, - " {name} — {} (source: {})", - entry.description.clone().unwrap_or_default(), - entry.source - ); - } - let _ = write!(out, "\nInstall with: /skill install <name>"); - CommandResult::message(out) - } - Ok(RegistryFetchResult::NeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(RegistryFetchResult::Denied(host)) => { - CommandResult::error(network_denied_message(&host)) - } - Err(err) => CommandResult::error(format_registry_error("Failed to fetch registry", &err)), - } -} - -// ─── /skills sync ────────────────────────────────────────────────────────── - -/// Fetch the remote registry index and download every listed skill into the -/// local cache (`~/.codewhale/cache/skills/<name>/`). -/// -/// For each skill the sync checks the cached ETag / SHA-256 before -/// downloading so unchanged skills are skipped in O(1) network round-trips. -fn sync_skills(app: &mut App) -> CommandResult { - let (network, max_size, registry_url) = installer_settings(app); - let cache_dir = install::default_cache_skills_dir(); - - let result = run_async(async move { - install::sync_registry(&network, ®istry_url, &cache_dir, max_size).await - }); - - match result { - Ok(SyncResult::RegistryDenied(host)) => CommandResult::error(network_denied_message(&host)), - Ok(SyncResult::RegistryNeedsApproval(host)) => { - CommandResult::error(needs_approval_message(&host)) - } - Ok(SyncResult::Done { outcomes }) => { - let total = outcomes.len(); - let mut downloaded = 0usize; - let mut fresh = 0usize; - let mut failed = 0usize; - let mut out = String::from("Registry sync complete.\n\n"); - - for outcome in &outcomes { - match outcome { - SkillSyncOutcome::Downloaded { name, path } => { - downloaded += 1; - let _ = writeln!(out, " [+] {name} — downloaded to {}", path.display()); - } - SkillSyncOutcome::Fresh { name } => { - fresh += 1; - let _ = writeln!(out, " [=] {name} — already up to date"); - } - SkillSyncOutcome::Failed { name, reason } => { - failed += 1; - let _ = writeln!(out, " [!] {name} — failed: {reason}"); - } - SkillSyncOutcome::Denied { name, host } => { - failed += 1; - let _ = writeln!(out, " [!] {name} — network denied ({host})"); - } - SkillSyncOutcome::NeedsApproval { name, host } => { - failed += 1; - let _ = writeln!( - out, - " [?] {name} — needs approval for {host} (run `/network allow {host}` then retry)" - ); - } - } - } - - let _ = write!( - out, - "\n{total} skill(s) processed: {downloaded} downloaded, {fresh} up-to-date, {failed} failed." - ); - - CommandResult::message(out) - } - Err(err) => CommandResult::error(format_registry_error("Sync failed", &err)), - } -} - -// ─── helpers ─────────────────────────────────────────────────────────────── - -/// Read the active config knobs for the installer. -/// -/// We load `Config::load` on demand because [`App`] does not carry a `Config` -/// field — and loading is cheap (small TOML file) compared to the network -/// round-trip the install/update operation will incur next. If the config -/// fails to parse, we fall back to defaults so the user still gets a -/// network-gated install rather than a silent crash. -fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) { - let cfg = crate::config::Config::load(None, None).unwrap_or_default(); - let network = cfg - .network - .clone() - .map(|policy| policy.into_runtime()) - .unwrap_or_default(); - let skills_cfg = cfg.skills.as_ref(); - let max_size = skills_cfg - .and_then(|s| s.max_install_size_bytes) - .unwrap_or(DEFAULT_MAX_SIZE_BYTES); - let registry_url = skills_cfg - .and_then(|s| s.registry_url.clone()) - .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); - (network, max_size, registry_url) -} - -fn run_async<F, T>(future: F) -> T -where - F: std::future::Future<Output = T>, -{ - // We're on the TUI's thread, which is part of the multi-threaded runtime. - // `block_in_place` + `Handle::current().block_on` bridges sync - // slash-command handlers back into the async ecosystem. - tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) -} - -fn path_or_default(path: &std::path::Path) -> String { - path.file_name() - .map(|n| { - // Display with parent so the user sees the full skill location. - // We intentionally use `display()` here because it's just for - // user-facing output, not for path comparisons. - let parent = path - .parent() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - if parent.is_empty() { - n.to_string_lossy().to_string() - } else { - format!("{parent}/{}", n.to_string_lossy()) - } - }) - .unwrap_or_else(|| path.display().to_string()) -} - -fn needs_approval_message(host: &str) -> String { - format!( - "Network policy requires approval for {host}.\n\ - Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry." - ) -} - -fn network_denied_message(host: &str) -> String { - format!( - "Network policy denied access to {host}.\n\ - Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator." - ) -} - -/// Inspect an anyhow chain and surface a one-line hint pointing at the most -/// common cause of a registry fetch failure (DNS, refused, TLS, HTTP status, -/// timeout). The chain itself is still rendered with `{err:#}`; this hint is -/// appended below it so users on `/skills --remote` and `/skills sync` get an -/// actionable next step instead of an opaque reqwest error. -fn registry_fetch_error_hint(err: &anyhow::Error) -> Option<&'static str> { - let msg = format!("{err:#}").to_lowercase(); - if msg.contains("dns") - || msg.contains("name resolution") - || msg.contains("getaddrinfo") - || msg.contains("nodename nor servname") - { - Some( - "Hint: DNS lookup failed. Check internet/DNS connectivity, or override the registry URL in [skills] of ~/.codewhale/config.toml.", - ) - } else if msg.contains("connection refused") - || msg.contains("connection reset") - || msg.contains("connection aborted") - { - Some( - "Hint: connection refused/reset. The registry host may be unreachable from this network (corporate proxy, firewall, offline).", - ) - } else if msg.contains("tls") - || msg.contains("certificate") - || msg.contains("ssl") - || msg.contains("handshake") - { - Some( - "Hint: TLS handshake failed. The system trust store may be missing the registry's CA, or a TLS-intercepting proxy is rewriting the certificate.", - ) - } else if msg.contains(" 404") || msg.contains("not found") { - Some( - "Hint: registry URL returned 404. Verify the registry URL in [skills] of ~/.codewhale/config.toml.", - ) - } else if msg.contains(" 401") || msg.contains(" 403") || msg.contains("forbidden") { - Some( - "Hint: registry returned an auth error. The registry may require credentials or have been moved.", - ) - } else if msg.contains(" 429") || msg.contains("rate limit") || msg.contains("too many") { - Some("Hint: rate-limited by the registry. Try again in a moment.") - } else if msg.contains("timed out") || msg.contains("timeout") { - Some("Hint: request timed out. Network may be slow or the registry host may be down.") - } else { - None - } -} - -fn format_registry_error(prefix: &str, err: &anyhow::Error) -> String { - let mut out = format!("{prefix}: {err:#}"); - if let Some(hint) = registry_fetch_error_hint(err) { - out.push_str("\n\n"); - out.push_str(hint); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use std::ffi::OsString; - use tempfile::TempDir; - - struct IsolatedHome { - _lock: std::sync::MutexGuard<'static, ()>, - home_prev: Option<OsString>, - userprofile_prev: Option<OsString>, - } - - impl IsolatedHome { - fn new(tmpdir: &TempDir) -> Self { - let lock = crate::test_support::lock_test_env(); - let home = tmpdir.path().join("home"); - std::fs::create_dir_all(&home).unwrap(); - let home_prev = std::env::var_os("HOME"); - let userprofile_prev = std::env::var_os("USERPROFILE"); - // SAFETY: tests that mutate process env hold the shared test env - // mutex for the full lifetime of this guard. - unsafe { - std::env::set_var("HOME", &home); - std::env::set_var("USERPROFILE", &home); - } - Self { - _lock: lock, - home_prev, - userprofile_prev, - } - } - - unsafe fn restore_var(key: &str, value: Option<OsString>) { - if let Some(value) = value { - unsafe { std::env::set_var(key, value) }; - } else { - unsafe { std::env::remove_var(key) }; - } - } - } - - impl Drop for IsolatedHome { - fn drop(&mut self) { - // SAFETY: the shared test env mutex is still held while Drop runs. - unsafe { - Self::restore_var("HOME", self.home_prev.take()); - Self::restore_var("USERPROFILE", self.userprofile_prev.take()); - } - } - } - - fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { - let options = TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: tmpdir.path().to_path_buf(), - 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: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - yolo: false, - resume_session_id: None, - initial_input: None, - }; - let mut app = App::new(options, &Config::default()); - app.skills_dir = tmpdir.path().join("skills"); - app - } - - fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) { - let skill_dir = tmpdir.path().join("skills").join(skill_name); - std::fs::create_dir_all(&skill_dir).unwrap(); - std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap(); - } - - #[test] - fn registry_fetch_error_hint_recognises_dns_failures() { - let err = anyhow::Error::msg("error sending request: dns error: failed to lookup") - .context("failed to fetch registry https://example.com/registry.json"); - let hint = registry_fetch_error_hint(&err).expect("dns hint"); - assert!(hint.contains("DNS"), "got: {hint}"); - } - - #[test] - fn registry_fetch_error_hint_recognises_connection_refused() { - let err = anyhow::Error::msg("error sending request: tcp connect: connection refused"); - let hint = registry_fetch_error_hint(&err).expect("refused hint"); - assert!(hint.contains("refused"), "got: {hint}"); - } - - #[test] - fn registry_fetch_error_hint_recognises_tls_failures() { - let err = anyhow::Error::msg("invalid peer certificate: UnknownIssuer (TLS handshake)"); - let hint = registry_fetch_error_hint(&err).expect("tls hint"); - assert!(hint.contains("TLS"), "got: {hint}"); - } - - #[test] - fn registry_fetch_error_hint_recognises_http_status_codes() { - let err_404 = anyhow::Error::msg("registry returned an error status: 404 Not Found"); - assert!( - registry_fetch_error_hint(&err_404) - .map(|h| h.contains("404")) - .unwrap_or(false) - ); - let err_429 = - anyhow::Error::msg("registry returned an error status: 429 Too Many Requests"); - assert!( - registry_fetch_error_hint(&err_429) - .map(|h| h.contains("rate")) - .unwrap_or(false) - ); - } - - #[test] - fn registry_fetch_error_hint_returns_none_for_unrecognised_errors() { - let err = anyhow::Error::msg("a totally novel error nobody anticipated"); - assert!(registry_fetch_error_hint(&err).is_none()); - } - - #[test] - fn format_registry_error_appends_hint_when_pattern_matches() { - let err = anyhow::Error::msg("dns error: nodename nor servname provided"); - let formatted = format_registry_error("Failed to fetch registry", &err); - assert!(formatted.starts_with("Failed to fetch registry: ")); - assert!( - formatted.contains("Hint: DNS"), - "expected hint, got: {formatted}" - ); - } - - #[test] - fn format_registry_error_omits_hint_when_no_pattern_matches() { - let err = anyhow::Error::msg("inscrutable opaque failure"); - let formatted = format_registry_error("Sync failed", &err); - assert_eq!(formatted, "Sync failed: inscrutable opaque failure"); - } - - #[test] - fn test_list_skills_empty_directory() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("No skills found")); - assert!(msg.contains("Skills location:")); - } - - #[test] - fn test_list_skills_with_skills() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "test-skill", - "---\nname: test-skill\ndescription: A test skill\n---\nDo something", - ); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Available skills")); - assert!(msg.contains("/test-skill")); - } - - #[test] - fn test_list_skills_filters_by_name_prefix() { - // #1318: a `/skills <prefix>` argument should narrow the list to - // skills whose names start with the prefix. The header reflects - // both the matched count and the registry total so the user - // knows what they're looking at. - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "alpha-skill", - "---\nname: alpha-skill\ndescription: First\n---\nbody", - ); - create_skill_dir( - &tmpdir, - "alphabet-helper", - "---\nname: alphabet-helper\ndescription: Helper\n---\nbody", - ); - create_skill_dir( - &tmpdir, - "beta-skill", - "---\nname: beta-skill\ndescription: Second\n---\nbody", - ); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, Some("alph")); - let msg = result.message.expect("filter result has message"); - - assert!(msg.contains("/alpha-skill")); - assert!(msg.contains("/alphabet-helper")); - assert!( - !msg.contains("/beta-skill"), - "beta-skill must be filtered out" - ); - assert!( - msg.contains("matching `alph`") && msg.contains("2 of 3"), - "header should show count + total, got: {msg}" - ); - } - - #[test] - fn test_list_skills_filter_is_case_insensitive() { - // Prefix matching is case-insensitive — typing `Alph` finds - // `alpha-skill` the same as `alph` does. - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "alpha-skill", - "---\nname: alpha-skill\ndescription: First\n---\nbody", - ); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, Some("ALPH")); - let msg = result.message.expect("case-insensitive filter has message"); - assert!(msg.contains("/alpha-skill")); - } - - #[test] - fn test_list_skills_filter_with_zero_matches_says_so() { - // When the prefix matches nothing, the message must say so - // explicitly (rather than printing an empty list) and point - // the user back at the unfiltered command. - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "alpha-skill", - "---\nname: alpha-skill\ndescription: First\n---\nbody", - ); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, Some("nonexistent")); - let msg = result.message.expect("zero-match filter still has message"); - assert!(msg.contains("No skills match prefix `nonexistent`")); - assert!(msg.contains("Run /skills")); - } - - #[test] - fn test_list_skills_rejects_flag_like_prefix() { - // `--remote` and `sync` stay reserved as subcommands; any other - // dash-prefixed argument is rejected so we don't silently turn - // a future flag into a no-match filter. - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, Some("--bogus")); - assert!( - result.is_error, - "expected usage error for --bogus, got: {result:?}" - ); - assert!( - result - .message - .as_deref() - .is_some_and(|m| m.contains("name-prefix")), - "expected --bogus error message to mention name-prefix, got: {result:?}" - ); - } - - #[test] - fn test_list_skills_renders_user_skills_under_your_skills_section() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "alpha-skill", - "---\nname: alpha-skill\ndescription: First skill\n---\nDo alpha work", - ); - create_skill_dir( - &tmpdir, - "beta-skill", - "---\nname: beta-skill\ndescription: Second skill\n---\nDo beta work", - ); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); - let msg = result.message.unwrap(); - - // User-created skills must appear in their own section so they - // stay visible even when many bundled skills are installed. - let section = msg - .find("Your skills") - .expect("user skills section header missing"); - let alpha = msg.find("/alpha-skill").expect("alpha skill should render"); - let beta = msg.find("/beta-skill").expect("beta skill should render"); - assert!( - alpha > section, - "alpha-skill should follow the header: {msg}" - ); - assert!(beta > section, "beta-skill should follow the header: {msg}"); - // Each entry on its own line with the description inline. - assert!(msg.contains("/alpha-skill - First skill"), "got: {msg}"); - assert!(msg.contains("/beta-skill - Second skill"), "got: {msg}"); - } - - #[test] - fn test_list_skills_merges_workspace_and_configured_dirs() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let workspace_skill_dir = tmpdir - .path() - .join(".agents") - .join("skills") - .join("workspace-skill"); - std::fs::create_dir_all(&workspace_skill_dir).unwrap(); - std::fs::write( - workspace_skill_dir.join("SKILL.md"), - "---\nname: workspace-skill\ndescription: Workspace skill\n---\nDo workspace work", - ) - .unwrap(); - create_skill_dir( - &tmpdir, - "configured-skill", - "---\nname: configured-skill\ndescription: Configured skill\n---\nDo configured work", - ); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = list_skills(&mut app, None); - let msg = result.message.unwrap(); - - assert!(msg.contains("/workspace-skill"), "got: {msg}"); - assert!(msg.contains("/configured-skill"), "got: {msg}"); - } - - #[test] - fn test_skill_subcommand_dispatch_install_usage() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - // Empty install spec → usage hint, not invalid-source error. - let result = run_skill(&mut app, Some("install")); - let msg = result.message.unwrap(); - assert!(msg.contains("/skill install"), "got: {msg}"); - } - - #[test] - fn test_skill_subcommand_dispatch_uninstall_missing() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = run_skill(&mut app, Some("uninstall absent-skill")); - let msg = result.message.unwrap(); - assert!(msg.contains("not installed"), "got: {msg}"); - } - - #[test] - fn test_run_skill_without_name() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = run_skill(&mut app, None); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Usage: /skill")); - } - - #[test] - fn test_run_skill_not_found() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = run_skill(&mut app, Some("nonexistent")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("not found")); - } - - #[test] - fn test_run_skill_activates() { - let tmpdir = TempDir::new().unwrap(); - let _home = IsolatedHome::new(&tmpdir); - create_skill_dir( - &tmpdir, - "test-skill", - "---\nname: test-skill\ndescription: A test skill\n---\nDo something special", - ); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = run_skill(&mut app, Some("test-skill")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Skill 'test-skill' activated")); - assert!(msg.contains("A test skill")); - assert!(app.active_skill.is_some()); - assert!(!app.history.is_empty()); - } -} diff --git a/crates/tui/src/commands/traits.rs b/crates/tui/src/commands/traits.rs new file mode 100644 index 000000000..9bee3ddae --- /dev/null +++ b/crates/tui/src/commands/traits.rs @@ -0,0 +1,124 @@ +//! Command trait, CommandGroup trait, and CommandRegistry. + +//! +//! Individual commands implement [`Command`], groups of commands implement +//! [`CommandGroup`], and the [`CommandRegistry`] collects all groups and +//! provides lookup + dispatch. +//! + +use std::collections::HashMap; + +use crate::localization::{Locale, MessageId}; +use crate::tui::app::App; + +use super::CommandResult; + +// --------------------------------------------------------------------------- +// CommandInfo — metadata carried by every command +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy)] +pub struct CommandInfo { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub usage: &'static str, + pub description_id: MessageId, +} + +impl CommandInfo { + pub fn requires_argument(&self) -> bool { + self.usage.contains('<') || self.usage.contains('[') + } + + pub fn palette_command(&self) -> String { + if self.requires_argument() { + format!("/{} ", self.name) + } else { + format!("/{}", self.name) + } + } + + pub fn description_for(&self, locale: Locale) -> &'static str { + crate::localization::tr(locale, self.description_id) + } + + pub fn palette_description_for(&self, locale: Locale) -> String { + let desc = self.description_for(locale); + if self.aliases.is_empty() { + desc.to_string() + } else { + format!("{} aliases: {}", desc, self.aliases.join(", ")) + } + } +} + +// --------------------------------------------------------------------------- +// Command trait +// --------------------------------------------------------------------------- + +pub trait Command: Send + Sync { + fn info(&self) -> &'static CommandInfo; + fn execute(&self, app: &mut App, args: Option<&str>) -> CommandResult; +} + +// --------------------------------------------------------------------------- +// CommandGroup trait +// --------------------------------------------------------------------------- + +pub trait CommandGroup: Send + Sync { + fn commands(&self) -> Vec<Box<dyn Command>>; +} + +// --------------------------------------------------------------------------- +// CommandRegistry +// --------------------------------------------------------------------------- + +pub struct CommandRegistry { + commands: Vec<Box<dyn Command>>, + name_to_index: HashMap<&'static str, usize>, +} + +impl CommandRegistry { + pub fn empty() -> Self { + Self { + commands: Vec::new(), + name_to_index: HashMap::new(), + } + } + + pub fn register(&mut self, cmd: Box<dyn Command>) { + let idx = self.commands.len(); + let info = cmd.info(); + self.name_to_index.insert(info.name, idx); + for alias in info.aliases { + self.name_to_index.insert(alias, idx); + } + self.commands.push(cmd); + } + + pub fn register_group(&mut self, group: &dyn CommandGroup) { + for cmd in group.commands() { + self.register(cmd); + } + } + + pub fn get(&self, name: &str) -> Option<&dyn Command> { + let name = name.strip_prefix('/').unwrap_or(name); + self.name_to_index + .get(name) + .and_then(|&idx| self.commands.get(idx)) + .map(Box::as_ref) + } + + pub fn get_info(&self, name: &str) -> Option<&'static CommandInfo> { + self.get(name).map(|cmd| cmd.info()) + } + + pub fn iter(&self) -> impl Iterator<Item = &dyn Command> { + self.commands.iter().map(Box::as_ref) + } + + pub fn infos(&self) -> Vec<&'static CommandInfo> { + self.iter().map(|cmd| cmd.info()).collect() + } +} diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 207fdc8f5..a5404d6ae 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -6,7 +6,7 @@ //! `/name`, the file contents are sent as a user message. //! //! Files may include optional YAML-like frontmatter between `---` markers. -//! Supported fields are `description`, `argument-hint`, and `allowed-tools`. +//! Supported fields are `description`, `argument-hint`, `allowed-tools`, and `pausable`. //! Frontmatter is stripped before the command body is sent to the model. //! //! ## Precedence @@ -206,6 +206,25 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe app.hunt.verdict = HuntVerdict::Hunting; app.hunt.token_budget = None; app.active_allowed_tools = None; + // Clear todos and plan state from the previous command. + match app.todos.try_lock() { + Ok(mut todos) => todos.clear(), + Err(_) => { + tracing::warn!(target: "pausable", "todos lock contended or poisoned — state not cleared"); + } + } + match app.plan_state.try_lock() { + Ok(mut plan) => *plan = crate::tools::plan::PlanState::default(), + Err(_) => { + tracing::warn!(target: "pausable", "plan_state lock contended or poisoned — state not cleared"); + } + } + // Clear any previous pause state — new command, fresh start. + app.paused = false; + app.pausable = false; + app.paused_cancelled = false; + app.paused_at = None; + app.active_snapshot = None; for (key, value) in &metadata { match key.as_str() { "description" => { @@ -215,6 +234,45 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe "allowed-tools" => { app.active_allowed_tools = Some(parse_allowed_tools(value)); } + "pausable" if value.trim().eq_ignore_ascii_case("true") => { + // Snapshot workspace for potential rollback via git stash + if let Some(snap_id) = app.active_snapshot.take() + && let Ok(repo) = + crate::snapshot::repo::SnapshotRepo::open_or_init(&app.workspace) + { + let _ = repo.restore(&crate::snapshot::repo::SnapshotId(snap_id)); + } + let git_stash_cmd = std::process::Command::new("git") + .args([ + "-C", + &app.workspace.to_string_lossy(), + "stash", + "push", + "--include-untracked", + "-m", + "codewhale-pausable", + ]) + .output(); + if let Ok(output) = git_stash_cmd { + if output.status.success() { + tracing::debug!(target: "pausable", "created git stash snapshot"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!(target: "pausable", "git stash failed: {stderr}"); + } + } + app.pausable = true; + app.paused = false; + app.paused_cancelled = false; + } + "pausable" => { + // Explicitly set pausable: false + app.pausable = false; + app.paused = false; + app.paused_cancelled = false; + app.active_snapshot = None; + tracing::debug!(target: "pausable", "pausable explicitly set to false"); + } _ => {} } } @@ -233,6 +291,7 @@ pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandRe /// /// `workspace` is used to also scan workspace-local command directories; /// pass `None` when no workspace context is available. +#[allow(dead_code)] pub fn user_commands_matching(prefix: &str, workspace: Option<&Path>) -> Vec<String> { let prefix = prefix.to_lowercase(); load_user_commands(workspace) diff --git a/crates/tui/src/config_actions.rs b/crates/tui/src/config_actions.rs new file mode 100644 index 000000000..6f30cbe0e --- /dev/null +++ b/crates/tui/src/config_actions.rs @@ -0,0 +1,1194 @@ +//! Runtime setting mutation helpers. + +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::config::{ + ApiProvider, COMMON_DEEPSEEK_MODELS, DEFAULT_XIAOMI_MIMO_BASE_URL, + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, normalize_model_name_for_provider, +}; +use crate::config_persistence::{ + persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key, +}; +use crate::localization::resolve_locale; +use crate::settings::Settings; +use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort, SidebarFocus, VimMode}; +use crate::tui::approval::ApprovalMode; +use anyhow::Result; + +fn expand_tilde(raw: &str) -> String { + if !raw.starts_with('~') { + return raw.to_string(); + } + let trimmed = raw.trim_start_matches('~'); + match std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { + Some(home) => PathBuf::from(home) + .join(trimmed) + .to_string_lossy() + .to_string(), + None => raw.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." + )), + } +} + +/// Modify a setting at runtime +pub(crate) fn set_config_value( + app: &mut App, + key: &str, + value: &str, + persist: bool, +) -> CommandResult { + let key = key.to_lowercase(); + + match key.as_str() { + "model" => { + // Support "/model auto" — auto-select model based on request complexity + 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()), + ); + } + // Clear auto mode when a specific model is set + 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.", + ); + } + _ => {} + } + + 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(); + } + "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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::groups::{ + config::config::config_impl::config_command, config::logout::logout_impl::logout, + config::mode::mode_impl::mode, config::settings::settings_impl::show_settings, + config::theme::theme_impl::theme, config::trust::trust_impl::trust, + }; + use crate::config::Config; + use crate::config_persistence::config_toml_path; + use crate::test_support::lock_test_env; + use crate::tui::app::{App, OnboardingState, 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"); + + // Safety: test-only environment mutation guarded by process-wide mutex. + 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() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("HOME", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("HOME"); + } + } + + if let Some(value) = self.userprofile.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("USERPROFILE", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("USERPROFILE"); + } + } + + if let Some(value) = self.codewhale_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("CODEWHALE_CONFIG_PATH"); + } + } + + if let Some(value) = self.deepseek_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + 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::new folds in saved TUI settings from the developer machine. + // Pin command tests back to DeepSeek semantics so model aliases are + // not normalized through a provider selected in an interactive run. + 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(); + // Switch to Agent first to guarantee a clean starting state regardless of + // user settings on the host machine. + 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 config_command_defaults_to_native_editor() { + let mut app = create_test_app(); + app.session.total_tokens = 1234; + let result = config_command(&mut app, None); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + } + + #[test] + fn config_command_native_arg_opens_legacy_editor() { + let mut app = create_test_app(); + let result = config_command(&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); + // Settings should load (may use defaults if file doesn't exist) + assert!(result.message.is_some()); + } + + #[test] + fn config_command_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_command_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_command_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_command_model_with_save_flag() { + let mut app = create_test_app(); + let _result = config_command(&mut app, Some("model deepseek-v4-flash --save")); + // Note: This test may fail in environments where settings can't be saved + // The important thing is that the model is updated + assert_eq!(app.model, "deepseek-v4-flash"); + } + + #[test] + fn test_set_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 = 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_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_command_approval_mode_valid_values() { + let mut app = create_test_app(); + // Test auto + let result = config_command(&mut app, Some("approval_mode auto")); + assert!(result.message.is_some()); + assert_eq!(app.approval_mode, ApprovalMode::Auto); + + // Test suggest + let result = config_command(&mut app, Some("approval_mode suggest")); + assert!(result.message.is_some()); + assert_eq!(app.approval_mode, ApprovalMode::Suggest); + + // Test never + 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_command_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_command_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_command_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(); + // Normalize trust state regardless of user settings on the host machine. + 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 config_command_rejects_invalid_setting() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("nonexistent value")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or("") + .contains("unknown setting") + ); + } + + #[test] + fn config_command_key_without_value_shows_current_setting() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("model")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("model = ")); + } +} diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs new file mode 100644 index 000000000..123fe0a34 --- /dev/null +++ b/crates/tui/src/config_persistence.rs @@ -0,0 +1,450 @@ +//! Config file path resolution and TOML persistence helpers. + +use std::path::{Path, PathBuf}; + +use crate::config::{ApiProvider, StatusItem, effective_home_dir, expand_path}; + +pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(None)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let tui_entry = table + .entry("tui".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let tui_table = tui_entry + .as_table_mut() + .context("`tui` section in config.toml must be a table")?; + let array = items + .iter() + .map(|item| toml::Value::String(item.key().to_string())) + .collect::<Vec<_>>(); + tui_table.insert("status_items".to_string(), toml::Value::Array(array)); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_root_string_key( + config_path: Option<&Path>, + key: &str, + value: &str, +) -> anyhow::Result<PathBuf> { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::String(value.to_string())); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_root_bool_key( + config_path: Option<&Path>, + key: &str, + value: bool, +) -> anyhow::Result<PathBuf> { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::Boolean(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +pub(crate) fn persist_provider_base_url_key( + config_path: Option<&Path>, + provider: ApiProvider, + value: &str, +) -> anyhow::Result<PathBuf> { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table")?; + let provider_key = provider_base_url_table_key(provider)?; + let entry = providers + .entry(provider_key.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{provider_key}` must be a table"))?; + entry.insert( + "base_url".to_string(), + toml::Value::String(value.to_string()), + ); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek uses the root base_url setting") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Volcengine => Ok("volcengine"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), + ApiProvider::Arcee => Ok("arcee"), + ApiProvider::Huggingface => Ok("huggingface"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> { + use anyhow::Context; + if let Some(path) = config_path { + return Ok(expand_path(path.to_string_lossy().as_ref())); + } + if let Ok(env) = std::env::var("CODEWHALE_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + if let Ok(env) = std::env::var("DEEPSEEK_CONFIG_PATH") { + let trimmed = env.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + let home = + effective_home_dir().context("failed to resolve home directory for config.toml path")?; + let primary = home.join(".codewhale").join("config.toml"); + if primary.exists() { + return Ok(primary); + } + let legacy = home.join(".deepseek").join("config.toml"); + if legacy.exists() { + return Ok(legacy); + } + Ok(primary) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::ffi::OsString; + use std::fs; + use std::path::Path; + 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"); + + // Safety: test-only environment mutation guarded by process-wide mutex. + 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() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("HOME", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("HOME"); + } + } + + if let Some(value) = self.userprofile.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("USERPROFILE", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("USERPROFILE"); + } + } + + if let Some(value) = self.codewhale_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("CODEWHALE_CONFIG_PATH"); + } + } + + if let Some(value) = self.deepseek_config_path.take() { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_CONFIG_PATH", value); + } + } else { + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + } + } + } + + #[test] + fn persist_status_items_writes_tui_section_to_config_toml() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-statusline-persist-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let items = vec![ + crate::config::StatusItem::Mode, + crate::config::StatusItem::Model, + crate::config::StatusItem::Cost, + ]; + + let path = persist_status_items(&items).expect("persist should succeed"); + let body = fs::read_to_string(&path).expect("written file should be readable"); + assert!(body.contains("[tui]"), "expected [tui] section in {body}"); + assert!( + body.contains("status_items"), + "expected status_items key in {body}" + ); + assert!(body.contains("\"mode\""), "expected mode key in {body}"); + assert!(body.contains("\"cost\""), "expected cost key in {body}"); + } + + #[test] + fn config_toml_path_uses_codewhale_home_for_fresh_installs() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-config-path-fresh-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!( + config_toml_path(None).unwrap(), + temp_root.join(".codewhale").join("config.toml") + ); + } + + #[test] + fn config_toml_path_preserves_legacy_config_when_it_exists() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-config-path-legacy-{}-{}", + std::process::id(), + nanos + )); + let legacy_config = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(legacy_config.parent().unwrap()).unwrap(); + fs::write(&legacy_config, "").unwrap(); + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::remove_var("DEEPSEEK_CONFIG_PATH"); + } + + assert_eq!(config_toml_path(None).unwrap(), legacy_config); + } + + #[test] + fn config_toml_path_prefers_codewhale_env_over_legacy_env() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-config-path-env-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let preferred = temp_root.join("preferred.toml"); + let legacy = temp_root.join("legacy.toml"); + + unsafe { + env::set_var("CODEWHALE_CONFIG_PATH", &preferred); + env::set_var("DEEPSEEK_CONFIG_PATH", &legacy); + } + + assert_eq!(config_toml_path(None).unwrap(), preferred); + } + + #[test] + fn persist_status_items_preserves_existing_unrelated_keys() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-statusline-preserve-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "api_key = \"sentinel-key\"\nmodel = \"deepseek-v4-pro\"\n", + ) + .unwrap(); + + let written = persist_status_items(&[crate::config::StatusItem::Mode]) + .expect("persist should succeed"); + let body = fs::read_to_string(&written).expect("written file should be readable"); + assert!( + body.contains("api_key = \"sentinel-key\""), + "round-trip lost api_key: {body}" + ); + assert!( + body.contains("model = \"deepseek-v4-pro\""), + "round-trip lost model: {body}" + ); + assert!( + body.contains("status_items"), + "expected status_items in {body}" + ); + } +} diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index d5632befe..d90d9bb84 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -8,7 +8,6 @@ use schemars::{JsonSchema, schema_for}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::commands; use crate::config::{Config, StatusItem, normalize_model_name}; use crate::localization::{normalize_configured_locale, resolve_locale}; use crate::settings::Settings; @@ -293,7 +292,7 @@ pub enum StatusItemValue { Balance, } -pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> { +pub(crate) fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> { let raw = arg.unwrap_or("").trim(); // Bare `/config` opens the legacy native modal — it matches the rest // of the codewhale-tui navy chrome out of the box. Power users can @@ -311,7 +310,7 @@ pub fn parse_mode(arg: Option<&str>) -> Result<ConfigUiMode, String> { Err("Usage: /config [native|tui|web]".to_string()) } -pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> { +pub(crate) fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> { let settings = Settings::load().unwrap_or_default(); let reasoning_effort = config .reasoning_effort() @@ -362,7 +361,7 @@ pub fn build_document(app: &App, config: &Config) -> Result<ConfigUiDocument> { }) } -pub fn build_schema() -> Value { +fn build_schema() -> Value { let mut schema = serde_json::to_value(schema_for!(ConfigUiDocument)).expect("config ui schema"); schema["title"] = Value::String("codewhale Config".to_string()); schema["description"] = @@ -371,7 +370,7 @@ pub fn build_schema() -> Value { } #[cfg(feature = "tui")] -pub fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> { +pub(crate) fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> { let document = build_document(app, config)?; let value = SchemaUI::new(serde_json::to_value(document.clone())?) .with_schema(build_schema()) @@ -389,7 +388,7 @@ pub fn run_tui_editor(app: &App, config: &Config) -> Result<ConfigUiDocument> { } #[cfg(feature = "web")] -pub async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSession> { +pub(crate) async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSession> { let initial = serde_json::to_value(build_document(app, config)?)?; let session = WebSessionBuilder::new(build_schema()) .with_initial_data(initial) @@ -472,7 +471,7 @@ pub async fn start_web_editor(app: &App, config: &Config) -> Result<WebConfigSes }) } -pub fn apply_document( +pub(crate) fn apply_document( doc: ConfigUiDocument, app: &mut App, config: &mut Config, @@ -554,7 +553,7 @@ pub fn apply_document( ), ("mcp_config_path", doc.config.mcp_config_path.as_str()), ] { - let result = commands::set_config_value(app, key, value, persist); + let result = crate::config_actions::set_config_value(app, key, value, persist); if result.is_error { bail!( "{}", @@ -573,7 +572,8 @@ pub fn apply_document( // the runtime model the user just chose when persist=false (#346-fix). if persist { let default_model_val = doc.settings.default_model.as_deref().unwrap_or("default"); - let result = commands::set_config_value(app, "default_model", default_model_val, true); + let result = + crate::config_actions::set_config_value(app, "default_model", default_model_val, true); if result.is_error { bail!( "{}", @@ -596,7 +596,7 @@ pub fn apply_document( app.status_items = new_status_items.clone(); app.needs_redraw = true; if persist { - let path = commands::persist_status_items(&new_status_items)?; + let path = crate::config_persistence::persist_status_items(&new_status_items)?; notes.push(format!("status_items saved to {}", path.display())); } else { notes.push("status_items updated for this session".to_string()); @@ -624,12 +624,12 @@ pub fn apply_document( }) } -pub fn parse_document(value: Value) -> Result<ConfigUiDocument> { +fn parse_document(value: Value) -> Result<ConfigUiDocument> { serde_json::from_value(value).context("failed to decode config ui document") } #[cfg(feature = "web")] -pub fn open_browser(url: &str) -> Result<()> { +pub(crate) fn open_browser(url: &str) -> Result<()> { crate::utils::open_url(url) } @@ -685,7 +685,7 @@ fn apply_reasoning_effort( app.last_effective_reasoning_effort = None; app.update_model_compaction_budget(); if persist { - commands::persist_root_string_key( + crate::config_persistence::persist_root_string_key( app.config_path.as_deref(), "reasoning_effort", effort.as_setting(), diff --git a/crates/tui/src/conversation_state.rs b/crates/tui/src/conversation_state.rs new file mode 100644 index 000000000..9681c453f --- /dev/null +++ b/crates/tui/src/conversation_state.rs @@ -0,0 +1,41 @@ +use crate::tui::app::App; + +/// Reset the active conversation without choosing the next session id. +pub(crate) fn reset_conversation_state(app: &mut App) -> bool { + app.clear_history(); + app.mark_history_updated(); + app.api_messages.clear(); + app.system_prompt = None; + app.viewport.transcript_selection.clear(); + app.queued_messages.clear(); + app.queued_draft = None; + app.session.total_tokens = 0; + app.session.total_conversation_tokens = 0; + app.session.reset_token_breakdown(); + app.session.session_cost = 0.0; + app.session.session_cost_cny = 0.0; + app.session.subagent_cost = 0.0; + app.session.subagent_cost_cny = 0.0; + app.session.subagent_cost_event_seqs.clear(); + app.session.displayed_cost_high_water = 0.0; + app.session.displayed_cost_high_water_cny = 0.0; + let todos_cleared = app.clear_todos(); + app.tool_log.clear(); + app.tool_cells.clear(); + app.tool_details_by_cell.clear(); + app.exploring_entries.clear(); + app.ignored_tool_calls.clear(); + app.pending_tool_uses.clear(); + app.last_exec_wait_command = None; + app.session.last_prompt_tokens = None; + app.session.last_completion_tokens = None; + app.session.last_prompt_cache_hit_tokens = None; + app.session.last_prompt_cache_miss_tokens = None; + app.session.last_reasoning_replay_tokens = None; + app.session.turn_cache_history.clear(); + app.session.last_cache_inspection = None; + app.session.last_warmup_key = None; + app.session.last_tool_catalog = None; + app.session.last_base_url = None; + todos_cleared +} diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fa2146171..6da4affd7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -439,6 +439,9 @@ pub struct EngineHandle { tx_user_input: mpsc::Sender<UserInputDecision>, /// Send steer input for an in-flight turn. tx_steer: mpsc::Sender<String>, + /// Shared paused flag — set by the UI, read by the turn loop. + /// Uses the same pattern as `cancel_token` to bypass the Op channel. + pub shared_paused: Arc<StdMutex<bool>>, } // `impl EngineHandle { ... }` moved to `engine/handle.rs` so the @@ -505,6 +508,9 @@ pub struct Engine { slop_ledger_gate_cache: Option<(Option<SystemTime>, Option<String>)>, /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. current_mode: AppMode, + /// Shared paused flag — set by the UI (via EngineHandle::set_paused), + /// read by the turn-loop pause gate. + shared_paused: Arc<StdMutex<bool>>, } // === Internal tool helpers === @@ -592,6 +598,7 @@ impl Engine { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let tool_exec_lock = Arc::new(RwLock::new(())); // Create clients for both providers @@ -754,6 +761,7 @@ impl Engine { workshop_vars, sandbox_backend, current_mode: AppMode::Agent, + shared_paused: shared_paused.clone(), }; engine.rehydrate_latest_canonical_state(); @@ -762,6 +770,7 @@ impl Engine { rx_event: Arc::new(RwLock::new(rx_event)), cancel_token: shared_cancel_token, cancel_reason, + shared_paused, tx_approval, tx_user_input, tx_steer, @@ -1034,6 +1043,23 @@ impl Engine { allowed_tools, hook_executor, } => { + let op_started = Instant::now(); + let content_bytes = content.len(); + let mode_label = mode.label().to_string(); + let model_label = model.clone(); + let reasoning_effort_label = reasoning_effort.clone(); + tracing::debug!( + target: "turn_dispatch", + content_bytes, + mode = %mode_label, + model = %model_label, + reasoning_effort = ?reasoning_effort_label, + auto_model, + allow_shell, + trust_mode, + auto_approve, + "engine received SendMessage op" + ); self.handle_send_message( content, mode, @@ -1052,6 +1078,14 @@ impl Engine { hook_executor, ) .await; + tracing::debug!( + target: "turn_dispatch", + content_bytes, + mode = %mode_label, + model = %model_label, + elapsed_ms = op_started.elapsed().as_millis(), + "engine finished SendMessage op" + ); } Op::RunShellCommand { command, @@ -1487,6 +1521,15 @@ In {new} mode: {policy}\n\n\ // Drain stale steer messages from previous turns. while self.rx_steer.try_recv().is_ok() {} + let content_bytes = content.len(); + tracing::debug!( + target: "turn_dispatch", + content_bytes, + mode = %mode.label(), + model = %model, + "engine handling SendMessage" + ); + // Create turn context first so start event includes a stable turn id. let mut turn = TurnContext::new(self.config.max_steps); self.turn_counter = self.turn_counter.saturating_add(1); @@ -1495,12 +1538,27 @@ In {new} mode: {policy}\n\n\ // Emit turn started event IMMEDIATELY so the UI knows the turn is // active. The snapshot below can take 30+ seconds on slow filesystems // (e.g. WSL2 /mnt/c) and must not delay the TurnStarted event. - let _ = self + let turn_started_result = self .tx_event .send(Event::TurnStarted { turn_id: turn.id.clone(), }) .await; + match turn_started_result { + Ok(()) => tracing::debug!( + target: "turn_dispatch", + turn_id = %turn.id, + turn_counter = self.turn_counter, + "engine emitted TurnStarted" + ), + Err(err) => tracing::warn!( + target: "turn_dispatch", + turn_id = %turn.id, + turn_counter = self.turn_counter, + ?err, + "engine failed to emit TurnStarted" + ), + } // Snapshot the workspace BEFORE we touch a single tool. Run the git // work on the blocking pool so the async runtime stays responsive; @@ -2609,11 +2667,13 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { let cancel_token = CancellationToken::new(); let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None)); + let shared_paused = Arc::new(StdMutex::new(false)); let handle = EngineHandle { tx_op, rx_event: Arc::new(RwLock::new(rx_event)), cancel_token: shared_cancel_token, cancel_reason, + shared_paused, tx_approval, tx_user_input, tx_steer, diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 1ed7e95d3..9341e9e3d 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -110,4 +110,21 @@ impl EngineHandle { self.tx_steer.send(content.into()).await?; Ok(()) } + + /// Pause or resume the current command (for pausable commands). + /// Sets a shared flag that the turn loop reads before every tool call. + /// Uses the same side-channel pattern as `cancel()` — bypasses the Op + /// channel so it takes effect immediately, even during a running turn. + pub fn set_paused(&self, paused: bool) { + match self.shared_paused.lock() { + Ok(mut slot) => { + *slot = paused; + tracing::debug!(target: "pausable", paused, "EngineHandle::set_paused"); + } + Err(poisoned) => *poisoned.into_inner() = paused, + } + // Note: Op::SetPaused was removed — the shared flag is the single + // source of truth. Sending an Op would make the engine fire a status + // event that races with cancel/continue status from the UI. + } } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index de71c5fa0..7fb123ade 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1357,6 +1357,16 @@ impl Engine { ))); } + // Pause gate: when the command is paused, cancel the turn + // instead of returning a tool error. Returning a tool error + // causes the model to try alternative approaches; cancelling + // the turn cleanly stops all execution. + let is_paused = self.shared_paused.lock().is_ok_and(|g| *g); + tracing::debug!(target: "pausable", is_paused, tool_name, "pause gate check"); + if blocked_error.is_none() && is_paused { + self.cancel_token.cancel(); + } + if blocked_error.is_none() && let Some(hook_executor) = self.config.hook_executor.as_ref() && hook_executor.has_hooks_for_event(crate::hooks::HookEvent::ToolCallBefore) diff --git a/crates/tui/src/eval.rs b/crates/tui/src/eval.rs index d3651613b..f9e05bb2f 100644 --- a/crates/tui/src/eval.rs +++ b/crates/tui/src/eval.rs @@ -11,41 +11,46 @@ use std::collections::BTreeMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::{Duration, Instant}; use tempfile::TempDir; +#[cfg(windows)] +use crate::shell_invocation::shell_program_stem; +use crate::shell_invocation::{ShellInvocation, shell_invocation}; #[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EvalShellPlatform { - Windows, - Unix, -} +use crate::shell_invocation::{ShellPlatform, ShellProbe, shell_invocation_for_platform}; -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -struct EvalShellInvocation { - program: &'static str, - args: Vec<String>, - raw_payload_on_windows: bool, +fn eval_shell_invocation(command: &str) -> ShellInvocation { + shell_invocation(command) } #[cfg(test)] fn eval_shell_invocation_for_platform( command: &str, - platform: EvalShellPlatform, -) -> EvalShellInvocation { - match platform { - EvalShellPlatform::Windows => EvalShellInvocation { - program: "cmd", - args: vec!["/C".to_string(), command.to_string()], - raw_payload_on_windows: true, - }, - EvalShellPlatform::Unix => EvalShellInvocation { - program: "sh", - args: vec!["-c".to_string(), command.to_string()], - raw_payload_on_windows: false, - }, + platform: ShellPlatform, + probe: &ShellProbe, +) -> ShellInvocation { + shell_invocation_for_platform(command, platform, probe) +} + +fn push_eval_shell_args(cmd: &mut Command, invocation: &ShellInvocation) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let is_cmd = shell_program_stem(&invocation.program).is_some_and(|stem| stem == "cmd"); + if invocation.raw_payload_on_windows + && is_cmd + && invocation.args.len() == 2 + && invocation.args[0].eq_ignore_ascii_case("/C") + { + cmd.raw_arg(&invocation.args[0]); + cmd.raw_arg(&invocation.args[1]); + return; + } } + + cmd.args(&invocation.args); } /// Representative tool steps covered by the evaluation harness. @@ -737,7 +742,22 @@ fn apply_patch(root: &Path, patch: &str) -> Result<()> { } fn exec_shell(root: &Path, command: &str) -> Result<String> { - crate::shell_dispatcher::global_dispatcher().run_foreground(command, root) + let invocation = eval_shell_invocation(command); + let mut cmd = Command::new(&invocation.program); + push_eval_shell_args(&mut cmd, &invocation); + let output = cmd + .current_dir(root) + .output() + .with_context(|| format!("failed to execute shell command: {command}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + return Err(anyhow::anyhow!( + "shell command failed (exit {}): {stderr}", + output.status.code().unwrap_or(-1) + )); + } + Ok(stdout) } fn truncate_output(value: &str, max_chars: usize) -> String { @@ -757,14 +777,70 @@ mod tests { fn eval_shell_invocation_preserves_quoted_payload_as_single_arg() { let command = r#"git commit -m "feat: complete sub-pages""#; - let windows = eval_shell_invocation_for_platform(command, EvalShellPlatform::Windows); - assert_eq!(windows.program, "cmd"); - assert_eq!(windows.args, vec!["/C".to_string(), command.to_string()]); + let windows = eval_shell_invocation_for_platform( + command, + ShellPlatform::Windows, + &ShellProbe { + comspec: Some("cmd.exe".to_string()), + ..ShellProbe::default() + }, + ); + assert_eq!(windows.program, "cmd.exe"); + assert_eq!( + windows.args, + vec!["/C".to_string(), format!("chcp 65001 >NUL & {command}")] + ); assert!(windows.raw_payload_on_windows); - let unix = eval_shell_invocation_for_platform(command, EvalShellPlatform::Unix); + let powershell = eval_shell_invocation_for_platform( + command, + ShellPlatform::Windows, + &ShellProbe { + pwsh_on_path: true, + ..ShellProbe::default() + }, + ); + assert_eq!(powershell.program, "pwsh.exe"); + assert_eq!( + powershell.args, + vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + command.to_string() + ] + ); + assert!(!powershell.raw_payload_on_windows); + + let unix = eval_shell_invocation_for_platform( + command, + ShellPlatform::Unix, + &ShellProbe::default(), + ); assert_eq!(unix.program, "sh"); assert_eq!(unix.args, vec!["-c".to_string(), command.to_string()]); assert!(!unix.raw_payload_on_windows); } + + #[cfg(windows)] + #[test] + fn push_eval_shell_args_uses_raw_arg_for_full_path_cmd() { + let invocation = ShellInvocation { + program: r"C:\Windows\System32\cmd.exe".to_string(), + args: vec![ + "/C".to_string(), + r#"chcp 65001 >NUL & git commit -m "quoted""#.to_string(), + ], + raw_payload_on_windows: true, + }; + + let mut cmd = Command::new("cmd"); + push_eval_shell_args(&mut cmd, &invocation); + let got: Vec<String> = cmd + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + assert_eq!(got, invocation.args); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 56be54f1a..7efb80bb0 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -27,7 +27,10 @@ mod compaction; mod composer_history; mod composer_stash; mod config; +mod config_actions; +mod config_persistence; mod config_ui; +mod conversation_state; mod core; mod cost_status; mod deepseek_theme; @@ -45,6 +48,7 @@ mod lsp; mod mcp; mod mcp_server; mod memory; +mod model_routing; mod models; mod network_policy; mod palette; @@ -68,7 +72,9 @@ mod seam_manager; mod session_failure_classifier; mod session_manager; mod settings; +mod share_export; mod shell_dispatcher; +mod shell_invocation; mod skill_state; mod skills; mod slop_ledger; @@ -5420,7 +5426,7 @@ struct CliAutoRoute { async fn resolve_cli_auto_route(config: &Config, model: &str, prompt: &str) -> CliAutoRoute { if model.trim().eq_ignore_ascii_case("auto") { let selection = - commands::resolve_auto_route_with_flash(config, prompt, "", "auto", "auto").await; + model_routing::resolve_auto_route_with_flash(config, prompt, "", "auto", "auto").await; CliAutoRoute { model: selection.model, reasoning_effort: selection.reasoning_effort, diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs new file mode 100644 index 000000000..206278fb6 --- /dev/null +++ b/crates/tui/src/model_routing.rs @@ -0,0 +1,606 @@ +//! Model selection and auto-routing. +//! +//! This module owns runtime model-routing decisions. It is used by the CLI, +//! TUI, runtime threads, and subagent tools, so it intentionally lives outside +//! the command tree. + +use crate::client::DeepSeekClient; +use crate::config::Config; +use crate::llm_client::LlmClient; +use crate::models::{ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt}; +use crate::tui::app::ReasoningEffort; +use std::time::Duration; + +pub(crate) fn auto_model_heuristic(input: &str, _current_model: &str) -> String { + auto_model_heuristic_with_bias(input, _current_model, false) +} + +/// `auto_model_heuristic` parameterised by the `[auto] cost_saving` opt-in +/// (#1207). When `cost_saving` is `true` the keyword set drops the borderline +/// triggers (`implement`, `analyze`) and the long-message length threshold +/// goes from 500 to 1000 — both shifts let "looks involved but might be a +/// one-liner" requests stay on Flash unless they actually look agentic. +fn auto_model_heuristic_with_bias(input: &str, _current_model: &str, cost_saving: bool) -> String { + auto_model_heuristic_selection_with_bias(input, _current_model, cost_saving).model +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AutoModelHeuristicConfidence { + Decisive, + Ambiguous, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AutoModelHeuristicSelection { + model: String, + confidence: AutoModelHeuristicConfidence, +} + +fn auto_model_heuristic_selection_with_bias( + input: &str, + _current_model: &str, + cost_saving: bool, +) -> AutoModelHeuristicSelection { + let len = input.chars().count(); + let lower = input.to_lowercase(); + let borderline_pro_keywords: &[&str] = &[ + "implement", + "analyze", + "\u{5b9e}\u{73b0}", // 实现 + "\u{5206}\u{6790}", // 分析 + "\u{5be6}\u{73fe}", // 實現 + ]; + let strong_match = COMPLEX_KEYWORDS + .iter() + .any(|kw| !borderline_pro_keywords.contains(kw) && lower.contains(kw)); + let borderline_match = borderline_pro_keywords.iter().any(|kw| lower.contains(kw)); + let pro_match = strong_match || (!cost_saving && borderline_match); + if pro_match { + return AutoModelHeuristicSelection { + model: "deepseek-v4-pro".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + // Short messages → Flash + if len < 100 { + return AutoModelHeuristicSelection { + model: "deepseek-v4-flash".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + // Long complex requests → Pro. Cost-saving raises the threshold so that + // long-but-routine requests (pasted logs, CSV-style data) don't escalate. + let long_threshold = if cost_saving { 1_000 } else { 500 }; + if len > long_threshold { + return AutoModelHeuristicSelection { + model: "deepseek-v4-pro".to_string(), + confidence: AutoModelHeuristicConfidence::Decisive, + }; + } + // Grey-zone default branch: Flash is the deterministic fallback, but the + // Flash router can still add value here + AutoModelHeuristicSelection { + model: "deepseek-v4-flash".to_string(), + confidence: AutoModelHeuristicConfidence::Ambiguous, + } +} + +const COMPLEX_KEYWORDS: &[&str] = &[ + // English (unchanged from the original list). + "refactor", + "architecture", + "design", + "debug", + "security", + "review", + "audit", + "migrate", + "optimize", + "rewrite", + "implement", + "analyze", + // Simplified Chinese. + "\u{91cd}\u{6784}", // 重构 + "\u{67b6}\u{6784}", // 架构 + "\u{8bbe}\u{8ba1}", // 设计 + "\u{8c03}\u{8bd5}", // 调试 + "\u{5b89}\u{5168}", // 安全 + "\u{5ba1}\u{67e5}", // 审查 + "\u{5ba1}\u{8ba1}", // 审计 + "\u{8fc1}\u{79fb}", // 迁移 + "\u{4f18}\u{5316}", // 优化 + "\u{91cd}\u{5199}", // 重写 + "\u{5b9e}\u{73b0}", // 实现 + "\u{5206}\u{6790}", // 分析 + // Traditional Chinese variants where they differ. + "\u{91cd}\u{69cb}", // 重構 + "\u{67b6}\u{69cb}", // 架構 + "\u{8a2d}\u{8a08}", // 設計 + "\u{8abf}\u{8a66}", // 調試 + "\u{5be9}\u{67e5}", // 審查 + "\u{5be9}\u{8a08}", // 審計 + "\u{9077}\u{79fb}", // 遷移 + "\u{512a}\u{5316}", // 優化 + "\u{91cd}\u{5beb}", // 重寫 + "\u{5be6}\u{73fe}", // 實現 +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AutoRouteRecommendation { + pub(crate) model: String, + pub(crate) reasoning_effort: Option<ReasoningEffort>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AutoRouteSource { + FlashRouter, + Heuristic, +} + +impl AutoRouteSource { + #[must_use] + pub(crate) fn label(self) -> &'static str { + match self { + AutoRouteSource::FlashRouter => "flash-router", + AutoRouteSource::Heuristic => "heuristic", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AutoRouteSelection { + pub(crate) model: String, + pub(crate) reasoning_effort: Option<ReasoningEffort>, + pub(crate) source: AutoRouteSource, +} + +const AUTO_MODEL_ROUTER_SYSTEM_PROMPT: &str = "\ +You are the codewhale auto-routing classifier. Return only compact JSON: \ +{\"model\":\"deepseek-v4-flash|deepseek-v4-pro\",\"thinking\":\"off|high|max\"}. \ +Use deepseek-v4-flash for trivial, conversational, status, or single-step work. \ +Use deepseek-v4-pro for coding, debugging, release work, multi-step tasks, high-risk decisions, \ +tool-heavy work, ambiguous requests, or anything that benefits from deeper reasoning. \ +Use thinking off only for trivial no-tool answers, high for ordinary reasoning, and max for \ +agentic, coding, multi-file, release, architecture, debugging, security, tool-heavy, or uncertain work."; + +/// Addendum appended to the auto-router system prompt when the user has opted in +/// to cost-saving mode. It nudges the LLM toward Flash for faintly-pro-keyword +/// requests that might otherwise look ambiguous but aren't genuinely complex. +const AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM: &str = "\ +\n\nCost-saving mode is ON. Prefer deepseek-v4-flash for any request that is \ +not unmistakably agentic, multi-step, architecture/design, security review, \ +or involves significant code generation or bug hunting. Do not escalate to \ +deepseek-v4-pro just because the user says \"implement\", \"analyze\", or sends \ +a very long message — those are weak signals and Flash can handle them. Reserve \ +Pro for genuinely complex, multi-file, multi-tool, or high-stakes work."; + +pub(crate) fn parse_auto_route_recommendation(raw: &str) -> Option<AutoRouteRecommendation> { + let value = extract_first_json_object(raw)?; + let model = value.get("model").and_then(serde_json::Value::as_str)?; + let model = normalize_auto_route_model(model)?; + let reasoning_effort = value + .get("thinking") + .or_else(|| value.get("reasoning_effort")) + .or_else(|| value.get("effort")) + .and_then(serde_json::Value::as_str) + .and_then(parse_auto_route_reasoning_effort); + + Some(AutoRouteRecommendation { + model: model.to_string(), + reasoning_effort, + }) +} + +fn extract_first_json_object(s: &str) -> Option<serde_json::Value> { + let bytes = s.as_bytes(); + let mut depth = 0usize; + let mut start: Option<usize> = None; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'{' => { + depth += 1; + if depth == 1 { + start = Some(i); + } + } + b'}' => { + if depth == 1 + && let Some(start) = start + { + let json_str = &s[start..=i]; + return serde_json::from_str(json_str).ok(); + } + depth = depth.saturating_sub(1); + } + _ => {} + } + } + None +} + +fn normalize_auto_route_model(model: &str) -> Option<&'static str> { + match model.trim().to_ascii_lowercase().as_str() { + "deepseek-v4-flash" | "flash" | "v4-flash" => Some("deepseek-v4-flash"), + "deepseek-v4-pro" | "pro" | "v4-pro" | "deepseek-v4" | "v4" => Some("deepseek-v4-pro"), + _ => None, + } +} + +fn parse_auto_route_reasoning_effort(effort: &str) -> Option<ReasoningEffort> { + match effort.trim().to_ascii_lowercase().as_str() { + "max" | "deep" | "3" => Some(ReasoningEffort::Max), + "on" | "high" | "medium" | "moderate" | "low" | "1" | "2" => Some(ReasoningEffort::High), + "off" | "none" | "minimum" | "0" => Some(ReasoningEffort::Off), + _ => None, + } +} + +pub(crate) fn normalize_auto_route_effort(effort: ReasoningEffort) -> ReasoningEffort { + effort +} + +fn auto_route_from_heuristic( + _latest_request: &str, + heuristic: AutoModelHeuristicSelection, +) -> AutoRouteSelection { + AutoRouteSelection { + model: heuristic.model, + reasoning_effort: None, + source: AutoRouteSource::Heuristic, + } +} + +async fn auto_route_flash_recommendation( + config: &Config, + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> anyhow::Result<Option<AutoRouteRecommendation>> { + if cfg!(test) { + return Ok(None); + } + + let client = DeepSeekClient::new(config)?; + let mut router_system = AUTO_MODEL_ROUTER_SYSTEM_PROMPT.to_string(); + if config.auto_cost_saving() { + router_system.push_str(AUTO_MODEL_ROUTER_COST_SAVING_ADDENDUM); + } + let request = MessageRequest { + model: "deepseek-v4-flash".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: auto_route_prompt( + latest_request, + recent_context, + selected_model_mode, + selected_thinking_mode, + ), + cache_control: None, + }], + }], + max_tokens: 96, + system: Some(SystemPrompt::Text(router_system)), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: Some(false), + temperature: Some(0.0), + top_p: None, + }; + + let response = + tokio::time::timeout(Duration::from_secs(4), client.create_message(request)).await??; + Ok(parse_auto_route_recommendation(&message_response_text( + &response, + ))) +} + +fn auto_route_prompt( + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> String { + format!( + "Session mode: agent\nSelected model mode: {}\nSelected thinking mode: {}\n\nRecent context:\n{}\n\nLatest user request:\n{}\n\nReturn JSON only.", + selected_model_mode, + selected_thinking_mode, + if recent_context.trim().is_empty() { + "No prior context." + } else { + recent_context + }, + truncate_for_auto_router(latest_request, 4_000) + ) +} + +fn message_response_text(response: &MessageResponse) -> String { + let mut out = String::new(); + for block in &response.content { + match block { + ContentBlock::Text { text, .. } | ContentBlock::ToolResult { content: text, .. } => { + append_router_text(&mut out, text); + } + ContentBlock::Thinking { thinking } => { + append_router_text(&mut out, thinking); + } + ContentBlock::ToolUse { name, .. } => { + append_router_text(&mut out, &format!("[tool call: {name}]")); + } + _ => {} + } + } + out +} + +fn append_router_text(out: &mut String, text: &str) { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(text); +} + +fn truncate_for_auto_router(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + truncated + } +} + +/// Resolve auto-route — heuristic first, then flash router for ambiguous cases. +pub(crate) async fn resolve_auto_route_with_flash( + config: &Config, + latest_request: &str, + recent_context: &str, + selected_model_mode: &str, + selected_thinking_mode: &str, +) -> AutoRouteSelection { + let cost_saving = config.auto_cost_saving(); + let heuristic = + auto_model_heuristic_selection_with_bias(latest_request, selected_model_mode, cost_saving); + if heuristic.confidence == AutoModelHeuristicConfidence::Decisive { + return auto_route_from_heuristic(latest_request, heuristic); + } + + match auto_route_flash_recommendation( + config, + latest_request, + recent_context, + selected_model_mode, + selected_thinking_mode, + ) + .await + { + Ok(Some(recommendation)) => AutoRouteSelection { + model: recommendation.model, + reasoning_effort: recommendation.reasoning_effort, + source: AutoRouteSource::FlashRouter, + }, + Ok(None) | Err(_) => auto_route_from_heuristic(latest_request, heuristic), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auto_model_heuristic_chinese_keywords_route_to_pro() { + // Without these keywords, a Chinese user typing + // "帮我重构这个模块" (37 chars in chars().count() terms after + // the leading helper text) fell through to the short-message + // Flash branch even though the intent is obviously Pro-tier. + for msg in [ + "\u{5e2e}\u{6211}\u{91cd}\u{6784}\u{8fd9}\u{4e2a}\u{6a21}\u{5757}", // 帮我重构这个模块 + "\u{8bbe}\u{8ba1}\u{6570}\u{636e}\u{5e93}\u{67b6}\u{6784}", // 设计数据库架构 + "\u{8c03}\u{8bd5}\u{5d29}\u{6e83}\u{95ee}\u{9898}", // 调试崩溃问题 + "\u{5ba1}\u{8ba1}\u{5b89}\u{5168}\u{6f0f}\u{6d1e}", // 审计安全漏洞 + "\u{8fc1}\u{79fb}\u{5230}\u{65b0}\u{6846}\u{67b6}", // 迁移到新框架 + "\u{4f18}\u{5316}\u{6027}\u{80fd}\u{74f6}\u{9888}", // 优化性能瓶颈 + "\u{5206}\u{6790}\u{8fd9}\u{6bb5}\u{4ee3}\u{7801}", // 分析这段代码 + ] { + assert_eq!( + auto_model_heuristic(msg, "auto"), + "deepseek-v4-pro", + "expected Pro for `{msg}`", + ); + } + } + + #[test] + fn auto_model_heuristic_traditional_chinese_keywords_route_to_pro() { + for msg in [ + "\u{8acb}\u{91cd}\u{69cb}\u{6b64}\u{6a21}\u{7d44}", // 請重構此模組 + "\u{67b6}\u{69cb}\u{8a2d}\u{8a08}", // 架構設計 + "\u{4ee3}\u{78bc}\u{8abf}\u{8a66}", // 代碼調試 + "\u{5be9}\u{8a08}\u{6f0f}\u{6d1e}", // 審計漏洞 + "\u{9077}\u{79fb}\u{5230}\u{65b0}\u{67b6}\u{69cb}", // 遷移到新架構 + "\u{512a}\u{5316}\u{6027}\u{80fd}", // 優化性能 + "\u{91cd}\u{5beb}\u{4ee3}\u{78bc}", // 重寫代碼 + "\u{5be6}\u{73fe}\u{65b0}\u{529f}\u{80fd}", // 實現新功能 + ] { + assert_eq!( + auto_model_heuristic(msg, "auto"), + "deepseek-v4-pro", + "expected Pro for `{msg}`", + ); + } + } + + #[test] + fn auto_model_heuristic_short_chinese_chat_stays_on_flash() { + // Sanity: a short non-keyword Chinese message still falls + // through to the cost-saving Flash branch. + // "你好" (2 chars) — well under the 100-char Flash floor. + assert_eq!( + auto_model_heuristic("\u{4f60}\u{597d}", "auto"), + "deepseek-v4-flash", + ); + } + + #[test] + fn auto_heuristic_selection_marks_short_and_complex_routes_decisive() { + let short = auto_model_heuristic_selection_with_bias("yes", "auto", false); + assert_eq!(short.model, "deepseek-v4-flash"); + assert_eq!( + short.confidence, + AutoModelHeuristicConfidence::Decisive, + "trivial replies should skip the Flash router" + ); + + let complex = auto_model_heuristic_selection_with_bias( + "Please review the auth migration", + "auto", + false, + ); + assert_eq!(complex.model, "deepseek-v4-pro"); + assert_eq!( + complex.confidence, + AutoModelHeuristicConfidence::Decisive, + "strong complexity keywords should skip the Flash router" + ); + } + + #[test] + fn auto_heuristic_selection_leaves_default_branch_ambiguous_for_router() { + let request = + "Please update the configuration notes so each option has a clearer label. ".repeat(3); + assert!( + (100..500).contains(&request.chars().count()), + "test request must stay in the default grey zone" + ); + + let selection = auto_model_heuristic_selection_with_bias(&request, "auto", false); + assert_eq!(selection.model, "deepseek-v4-flash"); + assert_eq!( + selection.confidence, + AutoModelHeuristicConfidence::Ambiguous, + "only the grey-zone default branch should invoke the Flash router" + ); + } + + #[test] + fn auto_route_recommendation_parses_strict_json() { + let rec = + parse_auto_route_recommendation(r#"{"model":"deepseek-v4-pro","thinking":"max"}"#) + .expect("valid router response should parse"); + + assert_eq!(rec.model, "deepseek-v4-pro"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Max)); + } + + #[test] + fn auto_route_recommendation_accepts_wrapped_json_aliases() { + let rec = + parse_auto_route_recommendation(r#"route: {"model":"flash","reasoning_effort":"off"}"#) + .expect("wrapped router response should parse"); + + assert_eq!(rec.model, "deepseek-v4-flash"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::Off)); + } + + #[test] + fn auto_route_recommendation_normalizes_legacy_low_medium_to_high() { + let rec = parse_auto_route_recommendation( + r#"{"model":"deepseek-v4-pro","reasoning_effort":"medium"}"#, + ) + .expect("medium should parse for back-compat"); + + assert_eq!(rec.model, "deepseek-v4-pro"); + assert_eq!(rec.reasoning_effort, Some(ReasoningEffort::High)); + } + + #[test] + fn auto_route_recommendation_rejects_unknown_model() { + assert!( + parse_auto_route_recommendation(r#"{"model":"some-other-model","thinking":"max"}"#,) + .is_none() + ); + } + + #[test] + fn auto_heuristic_default_routes_implement_to_pro() { + // Default (no cost-saving): "implement" is one of the borderline + // keywords that escalates to Pro. + assert_eq!( + auto_model_heuristic_with_bias("Please implement a binary search", "auto", false), + "deepseek-v4-pro" + ); + } + + #[test] + fn auto_heuristic_cost_saving_keeps_borderline_keywords_on_flash() { + // Cost-saving: "implement" / "analyze" are no longer enough to escalate. + assert_eq!( + auto_model_heuristic_with_bias("Please implement a binary search", "auto", true), + "deepseek-v4-flash" + ); + assert_eq!( + auto_model_heuristic_with_bias("analyze this snippet", "auto", true), + "deepseek-v4-flash" + ); + } + + #[test] + fn auto_heuristic_strong_keywords_still_route_to_pro_under_cost_saving() { + // Cost-saving must NOT swallow obviously Pro-grade work. + for kw in [ + "refactor", + "architecture", + "design", + "debug", + "security", + "review", + "audit", + "migrate", + "optimize", + "rewrite", + ] { + let req = format!("Please {kw} this module"); + assert_eq!( + auto_model_heuristic_with_bias(&req, "auto", true), + "deepseek-v4-pro", + "expected Pro for strong keyword `{kw}` even in cost-saving mode" + ); + } + } + + #[test] + fn auto_heuristic_cost_saving_raises_long_message_threshold() { + // 600-char request is "long" by default (>500) → Pro, + // but stays Flash under cost-saving (threshold 1000). + let body = "filler sentence. ".repeat(40); // ~680 chars + assert_eq!( + auto_model_heuristic_with_bias(&body, "auto", false), + "deepseek-v4-pro" + ); + assert_eq!( + auto_model_heuristic_with_bias(&body, "auto", true), + "deepseek-v4-flash" + ); + } + + #[test] + fn config_auto_cost_saving_defaults_to_false() { + let cfg = crate::config::Config::default(); + assert!(!cfg.auto_cost_saving()); + } + + #[test] + fn config_auto_cost_saving_reads_table() { + let cfg = crate::config::Config { + auto: Some(crate::config::AutoConfig { + cost_saving: Some(true), + }), + ..Default::default() + }; + assert!(cfg.auto_cost_saving()); + } +} diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 00584cd38..7dcc9037b 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -9,6 +9,7 @@ use crate::models::SystemPrompt; use crate::project_context::{ProjectContext, load_project_context_with_parents}; +use crate::shell_invocation::{shell_invocation, shell_program_stem}; use crate::tui::app::AppMode; use crate::tui::approval::ApprovalMode; use std::path::{Path, PathBuf}; @@ -149,10 +150,12 @@ for the current turn." fn render_environment_block(workspace: &Path, locale_tag: &str) -> String { let deepseek_version = env!("CARGO_PKG_VERSION"); let platform = std::env::consts::OS; - let shell = crate::shell_dispatcher::global_dispatcher() - .kind() - .binary() - .to_string(); + let shell = if cfg!(windows) { + let resolved = shell_invocation("").program; + shell_program_stem(&resolved).unwrap_or(resolved) + } else { + std::env::var("SHELL").unwrap_or_else(|_| "unknown".to_string()) + }; let pwd = workspace.display(); format!( @@ -1535,6 +1538,18 @@ mod tests { assert!(block.contains(&format!("- pwd: {}", tmp.path().display()))); assert!(block.contains("- platform:")); assert!(block.contains("- shell:")); + + #[cfg(windows)] + { + let shell_line = block + .lines() + .find(|line| line.starts_with("- shell: ")) + .expect("shell line present"); + assert!( + !shell_line.contains('\\') && !shell_line.contains('/'), + "Windows prompt shell should use the stem, not a full path" + ); + } } #[test] diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 6973a9c3d..9cd8f4e12 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1615,7 +1615,7 @@ impl RuntimeThreadManager { let requested_model = req.model.unwrap_or_else(|| thread.model.clone()); let auto_model = requested_model.trim().eq_ignore_ascii_case("auto"); let (model, reasoning_effort) = if auto_model { - let selection = crate::commands::resolve_auto_route_with_flash( + let selection = crate::model_routing::resolve_auto_route_with_flash( &self.config, &prompt, "", diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 22864c60a..735402c8c 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -51,6 +51,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; +use crate::shell_invocation::{ShellInvocation, shell_invocation}; + pub use policy::SandboxPolicy; /// Specification for a command to be executed, potentially within a sandbox. @@ -86,32 +88,11 @@ pub struct CommandSpec { impl CommandSpec { /// Create a `CommandSpec` for running a shell command via the platform shell. pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self { - let dispatcher = crate::shell_dispatcher::global_dispatcher(); - - #[cfg(windows)] - let (program, args) = { - // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the - // console output encoding directly. See issue #982. - let kind = dispatcher.kind(); - let cmd = if matches!( - kind, - crate::shell_dispatcher::ShellKind::Pwsh - | crate::shell_dispatcher::ShellKind::WindowsPowerShell - ) { - format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}") - } else if matches!(kind, crate::shell_dispatcher::ShellKind::Cmd) { - format!("chcp 65001 >NUL & {command}") - } else { - command.to_string() - }; - dispatcher.build_command_parts(&cmd) - }; - #[cfg(not(windows))] - let (program, args) = dispatcher.build_command_parts(command); + let invocation = shell_invocation(command); Self { - program, - args, + program: invocation.program, + args: invocation.args, cwd, env: HashMap::new(), timeout, @@ -159,49 +140,13 @@ impl CommandSpec { /// Get the original command as a single string (for display). pub fn display_command(&self) -> String { - if self.args.len() == 2 - && self.args[0] == "-c" - && matches!( - self.program.as_str(), - "sh" | "bash" | "/bin/sh" | "/bin/bash" | "/usr/bin/sh" | "/usr/bin/bash" - ) - { - // For shell commands, show the actual command - self.args[1].clone() - } else if self.args.len() == 2 - && self.args[0] == "-c" - && !self.program.eq_ignore_ascii_case("cmd") - && !self.program.eq_ignore_ascii_case("pwsh") - && !self.program.eq_ignore_ascii_case("pwsh.exe") - && !self.program.eq_ignore_ascii_case("powershell") - && !self.program.eq_ignore_ascii_case("powershell.exe") - { - self.args[1].clone() - } else if self.program.eq_ignore_ascii_case("cmd") - && self.args.len() == 2 - && self.args[0].eq_ignore_ascii_case("/C") - { - // Strip the `chcp 65001 >NUL & ` prefix we add on Windows for - // UTF-8 output (issue #982). - let raw = &self.args[1]; - raw.strip_prefix("chcp 65001 >NUL & ") - .unwrap_or(raw) - .to_string() - } else if { - let program = self.program.to_ascii_lowercase(); - program == "pwsh" - || program == "pwsh.exe" - || program == "powershell" - || program == "powershell.exe" - } && self.args.len() >= 3 - && self.args[0].eq_ignore_ascii_case("-NoProfile") - && self.args[1].eq_ignore_ascii_case("-Command") - { - // Strip the PowerShell encoding prefix. - let raw = &self.args[2]; - raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ") - .unwrap_or(raw) - .to_string() + let invocation = ShellInvocation { + program: self.program.clone(), + args: self.args.clone(), + raw_payload_on_windows: false, + }; + if let Some(command) = invocation.display_command() { + command } else { // For other commands, join program and args let mut parts = vec![self.program.clone()]; @@ -622,13 +567,25 @@ impl SandboxManager { mod tests { use super::*; + fn expected_shell_command(spec: &CommandSpec) -> Vec<String> { + let mut command = vec![spec.program.clone()]; + command.extend(spec.args.clone()); + command + } + #[test] fn test_command_spec_shell() { let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30)); - // Program and args depend on the detected shell. - assert!(!spec.program.is_empty(), "program must not be empty"); - assert!(!spec.args.is_empty(), "args must not be empty"); + #[cfg(windows)] + { + assert_windows_shell_spec_displays_command(&spec, "echo hello"); + } + #[cfg(not(windows))] + { + assert_eq!(spec.program, "sh"); + assert_eq!(spec.args, vec!["-c", "echo hello"]); + } assert_eq!(spec.display_command(), "echo hello"); } @@ -657,28 +614,14 @@ mod tests { let cmd = r#"git commit -m "feat: complete sub-pages""#; let spec = CommandSpec::shell(cmd, PathBuf::from("/tmp"), Duration::from_secs(30)); - let dispatcher = crate::shell_dispatcher::global_dispatcher(); - assert_eq!(spec.program, dispatcher.kind().binary()); - if dispatcher.kind().is_powershell() { - assert_eq!( - spec.args, - vec![ - dispatcher.kind().command_flag().to_string(), - "-Command".to_string(), - format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {cmd}") - ] - ); - } else { - let expected = if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) { - vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] - } else { - vec![ - dispatcher.kind().command_flag().to_string(), - cmd.to_string(), - ] - }; - assert_eq!(spec.args, expected); - // The quoted message is intact in a single argv slot — shell `-c` + #[cfg(windows)] + { + assert_windows_shell_spec_displays_command(&spec, cmd); + } + #[cfg(not(windows))] + { + assert_eq!(spec.program, "sh"); + assert_eq!(spec.args, vec!["-c".to_string(), cmd.to_string()]); // performs POSIX tokenization, yielding the correct argv: // ["git","commit","-m","feat: complete sub-pages"]. assert_eq!(spec.args.len(), 2); @@ -740,40 +683,33 @@ mod tests { .with_policy(SandboxPolicy::DangerFullAccess); let env = manager.prepare(&spec); - let dispatcher = crate::shell_dispatcher::global_dispatcher(); - assert_eq!(env.sandbox_type, SandboxType::None); - if dispatcher.kind().is_powershell() { - assert_eq!( - env.command, - vec![ - dispatcher.kind().binary().to_string(), - dispatcher.kind().command_flag().to_string(), - "-Command".to_string(), - "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; echo test" - .to_string(), - ] - ); - } else if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) { + assert_eq!(env.command, expected_shell_command(&spec)); + assert!(!env.is_sandboxed()); + } + + #[cfg(windows)] + fn assert_windows_shell_spec_displays_command(spec: &CommandSpec, command: &str) { + assert_eq!(spec.display_command(), command); + + let program = spec.program.replace('\\', "/").to_ascii_lowercase(); + if program.ends_with("/cmd.exe") || program == "cmd" || program == "cmd.exe" { + assert_eq!(spec.args[0], "/C"); + assert!(spec.args[1].ends_with(command)); + } else if program.ends_with("/pwsh.exe") + || program == "pwsh" + || program == "pwsh.exe" + || program.ends_with("/powershell.exe") + || program == "powershell" + || program == "powershell.exe" + { assert_eq!( - env.command, - vec![ - dispatcher.kind().binary().to_string(), - "/C".to_string(), - "chcp 65001 >NUL & echo test".to_string(), - ] + spec.args, + ["-NoProfile", "-NonInteractive", "-Command", command] ); } else { - assert_eq!( - env.command, - vec![ - dispatcher.kind().binary().to_string(), - dispatcher.kind().command_flag().to_string(), - "echo test".to_string(), - ] - ); + assert_eq!(spec.args, ["-c", command]); } - assert!(!env.is_sandboxed()); } #[test] diff --git a/crates/tui/src/commands/share.rs b/crates/tui/src/share_export.rs similarity index 61% rename from crates/tui/src/commands/share.rs rename to crates/tui/src/share_export.rs index 61f1dce6f..b226c4fc2 100644 --- a/crates/tui/src/commands/share.rs +++ b/crates/tui/src/share_export.rs @@ -1,86 +1,23 @@ -//! /share command — export the current session as a shareable web URL. -//! -//! Renders the current session transcript as a static HTML page, uploads it -//! to a GitHub Gist via the `gh` CLI, and displays the resulting URL. -//! -//! # Usage -//! -//! - `/share` — export the current session and print the Gist URL -//! - `/share help` — show usage +//! Render and upload a shareable session export. use std::io::Write; use std::path::Path; -use super::CommandResult; use crate::dependencies::ExternalTool; -use crate::tui::app::{App, AppAction}; - -/// Share the current session as a web URL. -pub fn share(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - - match raw { - "" => do_share(app), - "help" | "--help" | "-h" => CommandResult::message( - "/share — Export the current session as a shareable web URL.\n\ - \n\ - Usage:\n\ - /share Export and upload the current session\n\ - \n\ - The session transcript is rendered as static HTML and uploaded\n\ - to a GitHub Gist using the `gh` CLI. The Gist URL is displayed\n\ - so you can paste it into Slack, GitHub, Twitter, etc." - .to_string(), - ), - _ => CommandResult::error(format!( - "Unknown /share argument `{raw}`. Use `/share` with no arguments or `/share help`." - )), - } -} - -/// Export the session as HTML, upload to a Gist, and show the URL. -fn do_share(app: &mut App) -> CommandResult { - // Check if there's any session content to share - if app.history.is_empty() { - return CommandResult::error("Nothing to share. The current session is empty."); - } - - // Sanity-check: the extra info block is optional; the session itself - // is what we share. - let history_len = app.history.len(); - let model = &app.model; - let mode = app.mode.label(); - - // Use an AppAction to signal the engine to perform the async work. - CommandResult::with_message_and_action( - format!( - "Exporting {history_len} cell(s) from {model} ({mode}) session...\n\n\ - The session will be rendered as static HTML and uploaded to a GitHub Gist.\n\ - This requires the `gh` CLI to be installed and authenticated." - ), - AppAction::ShareSession { - history_len, - model: model.clone(), - mode: mode.to_string(), - }, - ) -} -/// Actually perform the share export. -/// -/// This is called from the engine after receiving the `ShareSession` action. -/// It renders the session as HTML and uploads it via `gh gist create`. -pub async fn perform_share(history_json: &str, model: &str, mode: &str) -> Result<String, String> { - // Build HTML from the session data +/// Render the session as HTML and upload it via `gh gist create`. +pub(crate) async fn perform_share( + history_json: &str, + model: &str, + mode: &str, +) -> Result<String, String> { let html = render_session_html(history_json, model, mode); - // Write to a temp file let tmp = match write_temp_html(&html) { Ok(file) => file, Err(e) => return Err(format!("Failed to write temp file: {e}")), }; - // Upload via `gh gist create` let url = match upload_gist(tmp.path()).await { Ok(url) => url, Err(e) => return Err(format!("Failed to upload Gist: {e}")), @@ -89,7 +26,6 @@ pub async fn perform_share(history_json: &str, model: &str, mode: &str) -> Resul Ok(url) } -/// Render the session as a standalone HTML page. fn render_session_html(history_json: &str, model: &str, mode: &str) -> String { let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); let escaped_model = html_escape(model); @@ -122,19 +58,18 @@ fn render_session_html(history_json: &str, model: &str, mode: &str) -> String { <body> <h1>codewhale Session</h1> <div class="meta"> - <strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br> + <strong>Model:</strong> {escaped_model} - <strong>Mode:</strong> {escaped_mode}<br> <strong>Exported:</strong> {timestamp} </div> <pre>{escaped_body}</pre> <div class="footer"> - Generated by codewhale · https://github.com/Hmbown/CodeWhale + Generated by codewhale - https://github.com/Hmbown/CodeWhale </div> </body> </html>"#, ) } -/// HTML-escape special characters. fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -143,7 +78,6 @@ fn html_escape(s: &str) -> String { .replace('\'', "'") } -/// Write HTML to a secure temp file and keep it alive for upload. fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> { let mut tmp = tempfile::Builder::new() .prefix("codewhale-share-") @@ -154,7 +88,6 @@ fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> { Ok(tmp) } -/// Upload a file as a GitHub Gist using the `gh` CLI. async fn upload_gist(path: &Path) -> Result<String, String> { let path_owned = path.to_path_buf(); let output = tokio::task::spawn_blocking(move || { diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs index a2063c410..b008c449d 100644 --- a/crates/tui/src/shell_dispatcher.rs +++ b/crates/tui/src/shell_dispatcher.rs @@ -116,8 +116,10 @@ impl ShellDispatcher { /// /// 1. `$env:SHELL` — WSL interop or Git Bash often set this. /// 2. `pwsh.exe` found on `PATH` — PowerShell 7+. - /// 3. `powershell.exe` found on `PATH` — Windows PowerShell 5.1. - /// 4. `cmd.exe` — always available, last resort. + /// 3. `cmd.exe` — always available, last resort. + /// + /// Windows PowerShell 5.1 can still be selected explicitly through + /// `$env:SHELL`, but is not used as an implicit fallback. /// /// ## Detection order (Unix) /// @@ -181,6 +183,7 @@ impl ShellDispatcher { if self.kind.needs_command_flag() { cmd.arg(self.kind.command_flag()); + cmd.arg("-NonInteractive"); cmd.arg("-Command"); cmd.arg(shell_command); } else if matches!(self.kind, ShellKind::Cmd) { @@ -208,6 +211,7 @@ impl ShellDispatcher { let args = if self.kind.needs_command_flag() { vec![ self.kind.command_flag().to_string(), + "-NonInteractive".to_string(), "-Command".to_string(), shell_command.to_string(), ] @@ -312,9 +316,6 @@ impl ShellDispatcher { if Self::find_exe("pwsh.exe") { return ShellKind::Pwsh; } - if Self::find_exe("powershell.exe") { - return ShellKind::WindowsPowerShell; - } ShellKind::Cmd } @@ -429,9 +430,22 @@ mod tests { }; let cmd = dispatcher.build_command("echo hello"); let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert!(args.contains(&"-NoProfile")); - assert!(args.contains(&"-Command")); - assert!(args.contains(&"echo hello")); + assert!( + args.contains(&"-NoProfile"), + "expected -NoProfile, got {args:?}" + ); + assert!( + args.contains(&"-NonInteractive"), + "expected -NonInteractive, got {args:?}" + ); + assert!( + args.contains(&"-Command"), + "expected -Command, got {args:?}" + ); + assert!( + args.contains(&"echo hello"), + "expected echo hello, got {args:?}" + ); } #[test] @@ -441,8 +455,11 @@ mod tests { }; let cmd = dispatcher.build_command("echo hello"); let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert!(args.contains(&"/C")); - assert!(args.contains(&"echo hello")); + assert!(args.contains(&"/C"), "expected /C, got {args:?}"); + assert!( + args.contains(&"echo hello"), + "expected echo hello, got {args:?}" + ); } #[test] @@ -452,8 +469,11 @@ mod tests { }; let cmd = dispatcher.build_command("echo hello"); let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert!(args.contains(&"-c")); - assert!(args.contains(&"echo hello")); + assert!(args.contains(&"-c"), "expected -c, got {args:?}"); + assert!( + args.contains(&"echo hello"), + "expected echo hello, got {args:?}" + ); } #[cfg(test)] @@ -491,15 +511,33 @@ mod tests { #[cfg(test)] #[test] fn build_command_quotes_spaces_for_cmd() { + // Regression: issue #1691: git commit -m "msg with spaces" must + // not be split into separate argv entries. let dispatcher = ShellDispatcher { kind: ShellKind::Cmd, }; let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert_eq!(args.len(), 2); + // cmd.exe /C receives the entire command as a single argument after /C. + // The args should be ["/C", "git commit -m \"msg with spaces\""]. + assert_eq!( + args.len(), + 2, + "expected 2 args (/C + command), got {args:?}" + ); assert_eq!(args[0], "/C"); - assert!(args[1].contains("msg with spaces")); - assert!(args[1].starts_with("git ")); + assert!( + args[1].contains("msg with spaces"), + "command string should contain the full quoted message, got: {}", + args[1] + ); + // The quoted message must not be split — if it were, args[1] would be + // just "git" and we'd see "commit", "-m", "\"msg", etc. + assert!( + args[1].starts_with("git "), + "command should start with 'git', got: {}", + args[1] + ); } #[cfg(test)] @@ -510,10 +548,45 @@ mod tests { }; let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert_eq!(args.len(), 3); + // pwsh.exe -NoProfile -NonInteractive -Command "<entire command>" + assert_eq!( + args.len(), + 4, + "expected 4 args (-NoProfile, -NonInteractive, -Command, payload), got {args:?}" + ); assert_eq!(args[0], "-NoProfile"); - assert_eq!(args[1], "-Command"); - assert!(args[2].contains("msg with spaces")); + assert_eq!(args[1], "-NonInteractive"); + assert_eq!(args[2], "-Command"); + assert!( + args[3].contains("msg with spaces"), + "payload should contain the full quoted message, got: {}", + args[3] + ); + } + + #[test] + fn build_command_quotes_spaces_for_sh() { + let dispatcher = ShellDispatcher { + kind: ShellKind::Sh, + }; + let cmd = dispatcher.build_command("git commit -m \"msg with spaces\""); + let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); + assert_eq!( + args.len(), + 2, + "expected 2 args (-c + command), got {args:?}" + ); + assert_eq!(args[0], "-c"); + assert!(args[1].contains("msg with spaces")); + } + + #[test] + fn global_dispatcher_is_singleton() { + let d1 = global_dispatcher(); + let d2 = global_dispatcher(); + // Same kind (can't compare pointers across LazyLock, but detect() + // is deterministic for a given environment so kind should match). + assert_eq!(d1.kind(), d2.kind()); } #[cfg(test)] diff --git a/crates/tui/src/shell_invocation.rs b/crates/tui/src/shell_invocation.rs new file mode 100644 index 000000000..e663f60fb --- /dev/null +++ b/crates/tui/src/shell_invocation.rs @@ -0,0 +1,403 @@ +//! Platform shell resolution for shell-command tools. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShellPlatform { + Windows, + Unix, +} + +impl ShellPlatform { + #[must_use] + pub(crate) fn current() -> Self { + if cfg!(windows) { + Self::Windows + } else { + Self::Unix + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShellInvocation { + pub(crate) program: String, + pub(crate) args: Vec<String>, + pub(crate) raw_payload_on_windows: bool, +} + +impl ShellInvocation { + #[must_use] + pub(crate) fn display_command(&self) -> Option<String> { + if self.program == "sh" && self.args.len() == 2 && self.args[0] == "-c" { + return Some(self.args[1].clone()); + } + + if shell_program_stem(&self.program) + .is_some_and(|stem| matches!(stem.as_str(), "bash" | "zsh" | "fish" | "sh")) + && self.args.len() == 2 + && self.args[0] == "-c" + { + return Some(self.args[1].clone()); + } + + if shell_program_stem(&self.program).is_some_and(|stem| stem == "cmd") + && self.args.len() == 2 + && self.args[0].eq_ignore_ascii_case("/C") + { + let raw = &self.args[1]; + return Some( + raw.strip_prefix("chcp 65001 >NUL & ") + .unwrap_or(raw) + .to_string(), + ); + } + + if shell_program_stem(&self.program) + .is_some_and(|stem| matches!(stem.as_str(), "pwsh" | "powershell")) + && let Some((idx, _)) = self + .args + .iter() + .enumerate() + .find(|(_, arg)| arg.eq_ignore_ascii_case("-Command")) + && let Some(command) = self.args.get(idx + 1) + { + return Some(command.clone()); + } + + None + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ShellProbe { + pub(crate) shell: Option<String>, + pub(crate) comspec: Option<String>, + pub(crate) pwsh_on_path: bool, +} + +impl ShellProbe { + #[must_use] + pub(crate) fn from_env() -> Self { + Self { + shell: std::env::var("SHELL") + .ok() + .filter(|value| !value.trim().is_empty()), + comspec: std::env::var("COMSPEC") + .ok() + .filter(|value| !value.trim().is_empty()), + pwsh_on_path: command_on_path("pwsh.exe") || command_on_path("pwsh"), + } + } +} + +#[must_use] +pub(crate) fn shell_invocation(command: &str) -> ShellInvocation { + shell_invocation_for_platform(command, ShellPlatform::current(), &ShellProbe::from_env()) +} + +#[must_use] +pub(crate) fn shell_invocation_for_platform( + command: &str, + platform: ShellPlatform, + probe: &ShellProbe, +) -> ShellInvocation { + match platform { + ShellPlatform::Unix => unix_shell_invocation(command), + ShellPlatform::Windows => windows_shell_invocation(command, probe), + } +} + +fn unix_shell_invocation(command: &str) -> ShellInvocation { + ShellInvocation { + program: "sh".to_string(), + args: vec!["-c".to_string(), command.to_string()], + raw_payload_on_windows: false, + } +} + +fn windows_shell_invocation(command: &str, probe: &ShellProbe) -> ShellInvocation { + if let Some(shell) = probe + .shell + .as_deref() + .and_then(|shell| invocation_from_shell_env(shell, command)) + { + return shell; + } + + // Default Windows resolution is intentionally pwsh.exe -> cmd.exe. Windows + // PowerShell 5.x can still be selected explicitly through SHELL, but it is + // not used as an implicit fallback. + if probe.pwsh_on_path { + return powershell_invocation("pwsh.exe", command); + } + + if let Some(comspec) = probe + .comspec + .as_deref() + .filter(|value| shell_program_stem(value).is_some_and(|stem| stem == "cmd")) + { + return cmd_invocation(comspec, command); + } + + cmd_invocation("cmd", command) +} + +fn invocation_from_shell_env(shell: &str, command: &str) -> Option<ShellInvocation> { + let stem = shell_program_stem(shell)?; + match stem.as_str() { + "pwsh" | "powershell" => Some(powershell_invocation(shell, command)), + "cmd" => Some(cmd_invocation(shell, command)), + "bash" | "zsh" | "fish" | "sh" => Some(posix_like_invocation( + windows_posix_shell_program(shell, &stem), + command, + )), + _ => None, + } +} + +fn cmd_invocation(program: &str, command: &str) -> ShellInvocation { + ShellInvocation { + program: program.to_string(), + args: vec!["/C".to_string(), format!("chcp 65001 >NUL & {command}")], + raw_payload_on_windows: true, + } +} + +fn powershell_invocation(program: &str, command: &str) -> ShellInvocation { + ShellInvocation { + program: program.to_string(), + args: vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + command.to_string(), + ], + raw_payload_on_windows: false, + } +} + +fn posix_like_invocation(program: &str, command: &str) -> ShellInvocation { + ShellInvocation { + program: program.to_string(), + args: vec!["-c".to_string(), command.to_string()], + raw_payload_on_windows: false, + } +} + +fn windows_posix_shell_program<'a>(shell: &'a str, stem: &'a str) -> &'a str { + if shell.trim_start().starts_with('/') { + stem + } else { + shell + } +} + +pub(crate) fn shell_program_stem(program: &str) -> Option<String> { + let normalized = program.trim().replace('\\', "/"); + let filename = normalized.rsplit('/').next()?.trim().to_ascii_lowercase(); + let stem = filename.strip_suffix(".exe").unwrap_or(&filename); + if stem.is_empty() { + None + } else { + Some(stem.to_string()) + } +} + +#[cfg(windows)] +fn command_on_path(program: &str) -> bool { + use std::path::PathBuf; + + let candidate = PathBuf::from(program); + if candidate.components().count() > 1 { + return candidate.is_file(); + } + + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path).any(|dir| dir.join(program).is_file()) +} + +#[cfg(not(windows))] +fn command_on_path(_program: &str) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe() -> ShellProbe { + ShellProbe::default() + } + + #[test] + fn unix_shell_stays_sh_c() { + let invocation = shell_invocation_for_platform("printf ok", ShellPlatform::Unix, &probe()); + assert_eq!(invocation.program, "sh"); + assert_eq!(invocation.args, ["-c", "printf ok"]); + assert!(!invocation.raw_payload_on_windows); + assert_eq!(invocation.display_command().as_deref(), Some("printf ok")); + } + + #[test] + fn windows_prefers_shell_env_powershell() { + let invocation = shell_invocation_for_platform( + r#"Remove-Item -Path "target file.txt" -Force"#, + ShellPlatform::Windows, + &ShellProbe { + shell: Some(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string()), + ..probe() + }, + ); + + assert_eq!( + invocation.program, + r"C:\Program Files\PowerShell\7\pwsh.exe" + ); + assert_eq!( + invocation.args, + [ + "-NoProfile", + "-NonInteractive", + "-Command", + r#"Remove-Item -Path "target file.txt" -Force"# + ] + ); + assert!(!invocation.raw_payload_on_windows); + assert_eq!( + invocation.display_command().as_deref(), + Some(r#"Remove-Item -Path "target file.txt" -Force"#) + ); + } + + #[test] + fn windows_shell_env_can_select_windows_powershell() { + let invocation = shell_invocation_for_platform( + "Get-ChildItem", + ShellPlatform::Windows, + &ShellProbe { + shell: Some( + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(), + ), + comspec: Some(r"C:\Windows\System32\cmd.exe".to_string()), + pwsh_on_path: false, + }, + ); + + assert_eq!( + invocation.program, + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + ); + assert_eq!( + invocation.args, + ["-NoProfile", "-NonInteractive", "-Command", "Get-ChildItem"] + ); + assert!(!invocation.raw_payload_on_windows); + assert_eq!( + invocation.display_command().as_deref(), + Some("Get-ChildItem") + ); + } + + #[test] + fn windows_uses_pwsh_before_cmd_when_available() { + let invocation = shell_invocation_for_platform( + "Get-ChildItem", + ShellPlatform::Windows, + &ShellProbe { + comspec: Some(r"C:\Windows\System32\cmd.exe".to_string()), + pwsh_on_path: true, + ..probe() + }, + ); + + assert_eq!(invocation.program, "pwsh.exe"); + assert_eq!( + invocation.args, + ["-NoProfile", "-NonInteractive", "-Command", "Get-ChildItem"] + ); + assert!(!invocation.raw_payload_on_windows); + } + + #[test] + fn windows_without_pwsh_falls_straight_to_cmd_not_windows_powershell() { + let invocation = shell_invocation_for_platform( + "git status --short", + ShellPlatform::Windows, + &ShellProbe { + comspec: Some( + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe".to_string(), + ), + pwsh_on_path: false, + ..probe() + }, + ); + + assert_eq!(invocation.program, "cmd"); + assert_eq!( + invocation.args, + ["/C", "chcp 65001 >NUL & git status --short"] + ); + assert!(invocation.raw_payload_on_windows); + assert_eq!( + invocation.display_command().as_deref(), + Some("git status --short") + ); + } + + #[test] + fn windows_falls_back_to_comspec_cmd_with_utf8_prefix() { + let invocation = shell_invocation_for_platform( + "git status --short", + ShellPlatform::Windows, + &ShellProbe { + comspec: Some(r"C:\Windows\System32\cmd.exe".to_string()), + pwsh_on_path: false, + ..probe() + }, + ); + + assert_eq!(invocation.program, r"C:\Windows\System32\cmd.exe"); + assert_eq!( + invocation.args, + ["/C", "chcp 65001 >NUL & git status --short"] + ); + assert!(invocation.raw_payload_on_windows); + assert_eq!( + invocation.display_command().as_deref(), + Some("git status --short") + ); + } + + #[test] + fn windows_honors_posix_like_shell_env() { + let invocation = shell_invocation_for_platform( + "printf ok", + ShellPlatform::Windows, + &ShellProbe { + shell: Some(r"C:\Program Files\Git\usr\bin\bash.exe".to_string()), + pwsh_on_path: true, + ..probe() + }, + ); + + assert_eq!(invocation.program, r"C:\Program Files\Git\usr\bin\bash.exe"); + assert_eq!(invocation.args, ["-c", "printf ok"]); + assert!(!invocation.raw_payload_on_windows); + } + + #[test] + fn windows_posix_shell_env_with_unix_path_uses_stem() { + let invocation = shell_invocation_for_platform( + "printf ok", + ShellPlatform::Windows, + &ShellProbe { + shell: Some("/usr/bin/bash".to_string()), + ..probe() + }, + ); + + assert_eq!(invocation.program, "bash"); + assert_eq!(invocation.args, ["-c", "printf ok"]); + } +} diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index d3521d198..74d326904 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -269,6 +269,7 @@ impl WindowsJob { ) .map_err(windows_io_error)?; + #[allow(clippy::unnecessary_cast)] let process_handle = HANDLE(child.as_raw_handle() as *mut core::ffi::c_void); AssignProcessToJobObject(job.handle, process_handle).map_err(windows_io_error)?; } diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 18d8f2212..06d311318 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -1172,48 +1172,76 @@ fn issue_1691_quoted_commit_message_round_trips() { Duration::from_secs(5), ); - let dispatcher = crate::shell_dispatcher::global_dispatcher(); - // The whole command (with quotes) is a single argv entry. The actual - // shell binary can vary by platform, but the payload itself must stay - // intact in one shell arg. We never split the command string ourselves. - assert_eq!(spec.program, dispatcher.kind().binary()); - if dispatcher.kind().is_powershell() { - assert_eq!( - spec.args, - [ - dispatcher.kind().command_flag().to_string(), - "-Command".to_string(), - format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {cmd}") - ] - ); - } else if matches!(dispatcher.kind(), crate::shell_dispatcher::ShellKind::Cmd) { - assert_eq!( - spec.args, - ["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] - ); - } else { - assert_eq!( - spec.args, - [ - dispatcher.kind().command_flag().to_string(), - cmd.to_string() - ] - ); + #[cfg(not(windows))] + { + // `sh -c <cmd>`: the whole command (with quotes) is a single argv + // entry. `sh` then POSIX-tokenizes it → correct git argv. We never + // split the command string ourselves. + assert_eq!(spec.program, "sh"); + assert_eq!(spec.args, ["-c".to_string(), cmd.to_string()]); + assert_eq!(spec.args.len(), 2); + + // push_shell_args is a faithful pass-through on Unix. + let mut built = Command::new(&spec.program); + push_shell_args(&mut built, &spec.program, &spec.args); + let got: Vec<String> = built + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(got, ["-c".to_string(), cmd.to_string()]); } - assert_eq!( - spec.args.len(), - if dispatcher.kind().is_powershell() { - 3 + + #[cfg(windows)] + { + let program = spec.program.replace('\\', "/").to_ascii_lowercase(); + if program.ends_with("/cmd.exe") || program == "cmd" || program == "cmd.exe" { + // `cmd /C <payload>`: payload carries the quotes verbatim. The fix + // routes /C + payload through `raw_arg` so `cmd.exe` (not MSVCRT) + // parses it, matching what a terminal does. + assert_eq!( + spec.args, + ["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")] + ); + } else if program.ends_with("/pwsh.exe") + || program == "pwsh" + || program == "pwsh.exe" + || program.ends_with("/powershell.exe") + || program == "powershell" + || program == "powershell.exe" + { + assert_eq!( + spec.args, + [ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + cmd.to_string() + ] + ); } else { - 2 + assert_eq!(spec.args, ["-c".to_string(), cmd.to_string()]); } - ); + let mut built = Command::new(&spec.program); + push_shell_args(&mut built, &spec.program, &spec.args); + let got: Vec<String> = built + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(got, spec.args); + } +} + +#[cfg(windows)] +#[test] +fn windows_cmd_fallback_still_uses_raw_args() { + let cmd = r#"git commit -m "feat: complete sub-pages""#; + let args = vec!["/C".to_string(), format!("chcp 65001 >NUL & {cmd}")]; - let mut built = Command::new(&spec.program); - push_shell_args(&mut built, &spec.program, &spec.args); + let mut built = Command::new("cmd"); + push_shell_args(&mut built, "cmd", &args); let got: Vec<String> = built .get_args() .map(|a| a.to_string_lossy().into_owned()) .collect(); - assert_eq!(got, spec.args); + assert_eq!(got, args); } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 9713a1562..5347cbbbe 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -4739,7 +4739,7 @@ fn fallback_subagent_assignment_route( let model = if let Some(model) = configured_model { model } else if runtime.auto_model { - crate::commands::auto_model_heuristic(prompt, &runtime.model) + crate::model_routing::auto_model_heuristic(prompt, &runtime.model) } else { runtime.model.clone() }; @@ -4765,7 +4765,7 @@ fn fallback_subagent_assignment_route( async fn subagent_flash_router( runtime: &SubAgentRuntime, prompt: &str, -) -> Result<Option<crate::commands::AutoRouteRecommendation>> { +) -> Result<Option<crate::model_routing::AutoRouteRecommendation>> { if cfg!(test) { return Ok(None); } @@ -4798,7 +4798,7 @@ async fn subagent_flash_router( runtime.client.create_message(request), ) .await??; - Ok(crate::commands::parse_auto_route_recommendation( + Ok(crate::model_routing::parse_auto_route_recommendation( &message_response_text(&response.content), )) } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3eb494f16..4a34c9e40 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1174,6 +1174,18 @@ pub struct App { /// Active tool restriction from custom slash command frontmatter. /// `None` means the current turn may use the normal tool set. pub active_allowed_tools: Option<Vec<String>>, + /// Whether the current pausable command is paused (ESC once). + pub paused: bool, + /// Timestamp of the last pause (for ESC debounce). + pub paused_at: Option<std::time::Instant>, + /// Whether the active command has `pausable: true` in frontmatter. + pub pausable: bool, + /// Whether the last pausable command was cancelled. + pub paused_cancelled: bool, + /// Snapshot ID for rollback of a pausable command. + pub active_snapshot: Option<String>, + /// Saved hunt.quarry from before pause — restored on "continue". + pub paused_quarry: Option<String>, pub history: Vec<HistoryCell>, pub history_version: u64, /// Per-cell revision counter, kept in lockstep with `history`. @@ -1985,6 +1997,12 @@ impl App { hunt: HuntState::default(), session: SessionState::default(), active_allowed_tools: None, + paused: false, + paused_at: None, + pausable: false, + paused_cancelled: false, + active_snapshot: None, + paused_quarry: None, history: Vec::new(), history_version: 0, history_revisions: Vec::new(), diff --git a/crates/tui/src/tui/auto_router.rs b/crates/tui/src/tui/auto_router.rs index 17fcc53ed..4d5414b90 100644 --- a/crates/tui/src/tui/auto_router.rs +++ b/crates/tui/src/tui/auto_router.rs @@ -4,12 +4,12 @@ //! The TUI calls `resolve_auto_model_selection` once per user turn when //! `app.auto_model` is set. The async function builds a recent-context //! summary from `api_messages` (capped to six rows of up to 900 chars -//! each), passes it through `commands::resolve_auto_route_with_flash`, +//! each), passes it through `model_routing::resolve_auto_route_with_flash`, //! and returns the selection (model + reasoning effort). The remaining //! helpers are pure transforms used to build that summary. -use crate::commands; use crate::config::Config; +use crate::model_routing; use crate::models::{ContentBlock, Message}; use crate::tui::app::{App, QueuedMessage, ReasoningEffort}; @@ -25,13 +25,13 @@ pub(super) async fn resolve_auto_model_selection( config: &Config, message: &QueuedMessage, latest_content: &str, -) -> commands::AutoRouteSelection { +) -> model_routing::AutoRouteSelection { let latest_request = if latest_content.trim().is_empty() { message.display.as_str() } else { latest_content }; - commands::resolve_auto_route_with_flash( + model_routing::resolve_auto_route_with_flash( config, latest_request, &recent_auto_router_context(&app.api_messages), @@ -43,7 +43,7 @@ pub(super) async fn resolve_auto_model_selection( /// Normalize the heuristic effort to the canonical auto-route effort. pub(super) fn normalize_auto_routed_effort(effort: ReasoningEffort) -> ReasoningEffort { - commands::normalize_auto_route_effort(effort) + model_routing::normalize_auto_route_effort(effort) } /// Build a compact recent-context summary for the auto-route prompt. diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 853b09aef..9136de9a7 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -55,7 +55,7 @@ pub fn build_entries( ) -> Vec<CommandPaletteEntry> { let mut entries = Vec::new(); - for command in commands::COMMANDS { + for command in commands::registry().infos() { let mut description = command.palette_description_for(locale); if command.requires_argument() { description.push_str(" "); diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index 708f4f97b..f119d883f 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -8,6 +8,7 @@ const COMPOSER_ARROW_SCROLL_LINES: usize = 3; pub(crate) enum EscapeAction { CloseSlashMenu, CancelRequest, + PauseCommand, DiscardQueuedDraft, ClearInput, Noop, @@ -16,8 +17,16 @@ pub(crate) enum EscapeAction { pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { if slash_menu_open { EscapeAction::CloseSlashMenu - } else if app.is_loading || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) { - EscapeAction::CancelRequest + } else if app.is_loading + || matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) + || app.pausable + || app.paused + { + if app.pausable && !app.paused { + EscapeAction::PauseCommand + } else { + EscapeAction::CancelRequest + } } else if app.queued_draft.is_some() && app.input.is_empty() { EscapeAction::DiscardQueuedDraft } else if !app.input.is_empty() { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index fa1b26ce9..539b39695 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -179,6 +179,12 @@ pub(crate) struct SidebarWorkSummary { strategy_explanation: Option<String>, strategy_steps: Vec<SidebarWorkStrategyStep>, state_updating: bool, + /// Optional pause indicator text ("(Paused)" or "(Cancelled)"). + pause_indicator: Option<String>, + /// True while app.paused is true (ESC pressed, tool calls blocked). + workflow_paused: bool, + /// True when app.paused_cancelled is true (ESC twice cancelling). + workflow_cancelled: bool, } impl SidebarWorkSummary { @@ -260,16 +266,36 @@ fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { }; Some(SidebarWorkSummary { - goal_objective: app.hunt.quarry.clone(), + goal_objective: if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }, goal_token_budget: app.hunt.token_budget, goal_completed: app.hunt.verdict == HuntVerdict::Hunted, goal_started_at: app.hunt.started_at, tokens_used: app.session.total_conversation_tokens, checklist_completion_pct, + pause_indicator: if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }, checklist_items, strategy_explanation, strategy_steps, state_updating: false, + workflow_paused: app.paused || app.paused_quarry.is_some(), + workflow_cancelled: app.paused_cancelled, }) })(); @@ -280,21 +306,61 @@ fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { if let Some(cached) = app.cached_work_summary.as_ref() { let mut summary = cached.clone(); - summary.goal_objective = app.hunt.quarry.clone(); + summary.goal_objective = if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }; summary.goal_token_budget = app.hunt.token_budget; summary.goal_completed = app.hunt.verdict == HuntVerdict::Hunted; summary.goal_started_at = app.hunt.started_at; summary.tokens_used = app.session.total_conversation_tokens; + summary.workflow_paused = app.paused || app.paused_quarry.is_some(); + summary.workflow_cancelled = app.paused_cancelled; + summary.pause_indicator = if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }; return summary; } SidebarWorkSummary { - goal_objective: app.hunt.quarry.clone(), + goal_objective: if app.paused_quarry.is_some() || app.paused_cancelled { + app.hunt + .quarry + .clone() + .or_else(|| app.paused_quarry.clone()) + } else { + app.hunt.quarry.clone() + }, goal_token_budget: app.hunt.token_budget, goal_completed: app.hunt.verdict == HuntVerdict::Hunted, goal_started_at: app.hunt.started_at, tokens_used: app.session.total_conversation_tokens, state_updating: true, + workflow_paused: app.paused || app.paused_quarry.is_some(), + workflow_cancelled: app.paused_cancelled, + pause_indicator: if app.paused || app.paused_quarry.is_some() { + Some(if app.is_loading { + "(Pausing)".to_string() + } else { + "(Paused)".to_string() + }) + } else if app.paused_cancelled { + Some("(Cancelled)".to_string()) + } else { + None + }, ..SidebarWorkSummary::default() } } @@ -345,14 +411,30 @@ fn push_work_goal_lines( return; } - let icon = if summary.goal_completed { "✓" } else { "◆" }; + let icon = if summary.goal_completed { + "✓" + } else if summary.workflow_cancelled { + "✘" + } else if summary.workflow_paused { + "⏸" + } else { + "▶" + }; let status_style = if summary.goal_completed { Style::default() .fg(theme.success) .add_modifier(ratatui::style::Modifier::BOLD) + } else if summary.workflow_cancelled { + Style::default() + .fg(theme.error_fg) + .add_modifier(ratatui::style::Modifier::BOLD) + } else if summary.workflow_paused { + Style::default() + .fg(theme.accent_primary) + .add_modifier(ratatui::style::Modifier::BOLD) } else { Style::default() - .fg(theme.warning) + .fg(theme.status_working) .add_modifier(ratatui::style::Modifier::BOLD) }; @@ -3016,4 +3098,902 @@ mod tests { // Mouse outside content area (above) — row < content_area.y assert!((1u16) < section.content_area.y); } + + // ── Pause lifecycle tests ──────────────────────────────────────── + + #[test] + fn pause_indicator_none_when_not_paused_or_cancelled() { + let mut app = create_test_app(); + app.paused = false; + app.paused_cancelled = false; + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "expected no indicator when not paused/cancelled" + ); + } + + #[test] + fn pause_indicator_paused_when_paused() { + let mut app = create_test_app(); + app.paused = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Paused)")); + } + + #[test] + fn pause_indicator_pausing_when_loading() { + let mut app = create_test_app(); + app.paused = true; + app.is_loading = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Pausing)")); + } + + #[test] + fn pause_indicator_cancelled_when_cancelled() { + let mut app = create_test_app(); + app.paused_cancelled = true; + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Cancelled)")); + } + + #[test] + fn goal_icon_play_when_running() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: None, + workflow_paused: false, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('▶'), + "expected play icon for running, got: {first}" + ); + } + + #[test] + fn goal_icon_check_when_completed() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: true, + pause_indicator: None, + workflow_paused: false, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('✓'), + "expected checkmark for completed, got: {first}" + ); + } + + #[test] + fn goal_icon_pause_when_paused() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: Some("(Paused)".to_string()), + workflow_paused: true, + workflow_cancelled: false, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('⏸'), + "expected pause icon for paused, got: {first}" + ); + } + + #[test] + fn goal_icon_cancelled_when_cancelled() { + let summary = SidebarWorkSummary { + goal_objective: Some("test".to_string()), + goal_completed: false, + pause_indicator: Some("(Cancelled)".to_string()), + workflow_paused: false, + workflow_cancelled: true, + ..SidebarWorkSummary::default() + }; + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + assert!( + first.contains('✘'), + "expected cross icon for cancelled, got: {first}" + ); + } + + #[test] + fn cancel_status_not_overwritten_by_late_paused_event() { + // Simulate what the Event::Status handler in ui.rs does: + // when paused_cancelled is true, "Paused"/"Resumed" events are discarded. + let mut app = create_test_app(); + app.paused_cancelled = true; + app.status_message = Some("Request was Cancelled".to_string()); + + // Simulate the guard from EngineEvent::Status handler in ui.rs: + let late_message = "Request was Paused".to_string(); + if !(app.paused_cancelled + && (late_message == "Request was Paused" || late_message == "Request was Resumed")) + { + app.status_message = Some(late_message); + } + + assert_eq!( + app.status_message.as_deref(), + Some("Request was Cancelled"), + "guard must discard late Paused event when cancelled" + ); + } + + #[test] + fn pause_sets_pause_message_as_quarry() { + // When the user pauses a command, the quarry must be set to a pause + // message so the system prompt tells the model the command is on hold. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + + // Simulate PauseCommand handler saving + clearing the quarry: + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry must save the original goal" + ); + assert!( + app.hunt.quarry.is_none(), + "hunt.quarry must be None so goal system doesn't prompt model to resolve a pause goal" + ); + assert!(app.paused, "app.paused must be true"); + } + + #[test] + fn completed_command_clears_quarry() { + // When a command completes successfully, the verdict is set to Hunted + // but the quarry is kept so the WorkBench shows the goal with ✓. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + // Simulate successful completion (what the TurnCompleted handler does): + if app.hunt.quarry.is_some() { + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + // quarry is NOT cleared — sidebar shows completed goal + } + + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan repos"), + "quarry must persist after completion so WorkBench shows the goal" + ); + assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunted); + let summary = sidebar_work_summary(&mut app); + assert!(summary.goal_completed, "completed flag must be true"); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan repos"), + "WorkBench must show the completed goal text" + ); + } + + #[test] + fn cancel_clears_hunt_goal_so_model_does_not_resume_old_command() { + // Simulate a running pausable command with a description (hunt.quarry). + // When cancelled, the hunt goal must be cleared so the model does NOT + // see the old objective in the system prompt for the next turn. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.hunt.started_at = Some(std::time::Instant::now()); + app.active_allowed_tools = Some(vec!["exec_shell".to_string()]); + app.paused_cancelled = false; + app.paused = true; + app.pausable = true; + + // Simulate CancelRequest handler clearing on cancel: + // quarry is NOT cleared here — it persists so the WorkBench shows + // the cancelled goal. It is cleared in dispatch_user_message when + // the user sends a new message. + app.paused_cancelled = true; + app.paused = false; + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.active_allowed_tools = None; + + // Quarry is kept visible for WorkBench display + assert_eq!( + app.hunt.quarry.as_deref(), + Some("Scan nested git repositories"), + "quarry must persist on cancel so WorkBench shows the cancelled goal" + ); + // Simulate dispatch_user_message clearing on next user message: + app.hunt.quarry = None; + assert!( + app.hunt.quarry.is_none(), + "quarry cleared in dispatch_user_message when user sends next message" + ); + assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting); + assert!( + app.active_allowed_tools.is_none(), + "tool restriction must be cleared on cancel" + ); + } + + #[test] + fn new_slash_command_after_cancel_clears_all_pause_state() { + // Simulate the full lifecycle: + // 1. Command running → cancel (paused_cancelled=true) + // 2. User types /git-scan → try_dispatch_user_command clears all state + // 3. New command should have NO trace of old pause state + let mut app = create_test_app(); + app.paused_cancelled = true; + app.paused = false; + app.pausable = false; + app.active_snapshot = Some("stash".to_string()); + app.hunt.quarry = Some("old scan".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + + // Simulate try_dispatch_user_command clearing: + app.paused_cancelled = false; + app.active_snapshot = None; + app.hunt.quarry = Some("new task".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.goal_objective.as_deref(), Some("new task")); + assert!( + summary.pause_indicator.is_none(), + "new command must have no pause indicator, got: {:?}", + summary.pause_indicator + ); + } + + // ── Full lifecycle workflow tests ─────────────────────────────── + + #[test] + fn lifecycle_pause_continue_complete() { + // 1. Start pausable command + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + app.is_loading = true; + + // → WorkBench should show "▶" play icon + let summary = sidebar_work_summary(&mut app); + assert!(!summary.goal_completed, "command is still running"); + assert!(summary.pause_indicator.is_none(), "not paused yet"); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "goal must show original description" + ); + + // 2. User presses ESC — pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + app.pausable = true; + app.is_loading = false; // past tool drain + + // → WorkBench should show "⏸ (Paused)" + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "must show paused after ESC" + ); + // quarry is cleared for system prompt, but paused_quarry keeps + // the goal visible in the WorkBench (with ⏸ icon). + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench must show the goal even when paused (from paused_quarry)" + ); + + // 3. User types "continue" — restore quarry, unpause + app.hunt.quarry = app.paused_quarry.take(); + app.paused = false; + app.pausable = false; + app.is_loading = true; + + // → WorkBench should show "▶" play icon with original goal + let summary = sidebar_work_summary(&mut app); + assert!(!summary.goal_completed, "continue -> still running"); + assert!( + summary.pause_indicator.is_none(), + "pause indicator must be gone after resume" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "original goal must be restored on continue" + ); + + // 4. Command completes successfully + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + app.hunt.quarry = None; + app.is_loading = false; + + // → WorkBench should show "✓" green checkmark + let summary = sidebar_work_summary(&mut app); + assert!(summary.goal_completed, "must be marked completed"); + assert!( + summary.pause_indicator.is_none(), + "no pause indicator when completed" + ); + assert!( + app.hunt.quarry.is_none(), + "quarry cleared so model is not prompted to continue" + ); + } + + #[test] + fn lifecycle_pause_cancel_new_command() { + // 1. Start pausable command + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. User presses ESC — pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + assert!(app.paused, "must be paused"); + assert!(app.paused_quarry.is_some(), "original goal must be saved"); + assert!(app.hunt.quarry.is_none(), "active goal must be cleared"); + + // 3. User presses ESC again — cancel (simulate CancelRequest handler) + // Restore quarry from paused_quarry so WorkBench shows the goal. + app.paused = false; + app.pausable = false; + app.paused_cancelled = true; + if let Some(saved) = app.paused_quarry.take() { + app.hunt.quarry = Some(saved); + } + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + // → WorkBench should show "✘ (Cancelled)" with the original goal + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Cancelled)"), + "must show cancelled indicator" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "workbench must show the original goal even when cancelled" + ); + assert!( + app.paused_quarry.is_none(), + "paused_quarry cleared on cancel" + ); + + // 4. User starts a fresh slash command + app.paused_cancelled = false; + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "new command must have no pause indicator" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Deploy to staging"), + "new command's goal must be shown" + ); + } + + #[test] + fn non_continue_while_paused_clears_system_prompt_goal() { + // When the user types something while paused (not "continue"/"resume"), + // the hunt.quarry must be None so the system prompt has no goal. + // This prevents the model from being prompted to continue the old command. + // The paused_quarry is preserved for WorkBench display. + let mut app = create_test_app(); + app.hunt.quarry = None; // set by PauseCommand handler + app.paused_quarry = Some("Scan repos".to_string()); + app.paused = true; + + // Simulate dispatch_user_message for a non-continue message: + app.paused = false; + app.paused_cancelled = false; + app.pausable = false; + // app.hunt.quarry stays None — NOT restored + + assert!( + app.hunt.quarry.is_none(), + "quarry must stay None for non-continue: system prompt has no goal" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan repos"), + "paused_quarry preserved for WorkBench" + ); + } + + /// Simulate what dispatch_user_message does when processing a message + /// while the app is paused. Mirrors the real flow in ui.rs so tests + /// catch regressions that state-only tests miss. + fn simulate_dispatch_non_continue(app: &mut App, _message: &str) { + // No keyword interception: the LLM evaluation note handles intent. + if app.paused || app.paused_quarry.is_some() { + if app.paused_quarry.is_some() { + app.hunt.quarry = None; + } + if app.paused { + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + app.is_loading = false; + } + } + } + + #[test] + fn dispatch_non_continue_preserves_workbench_and_clears_system_goal() { + // Real flow: start -> ESC (pause) -> "how are you?" + // Must: keep WorkBench visible AND clear system prompt goal. + let mut app = create_test_app(); + + // 1. Start pausable command + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. ESC -- pause (what PauseCommand handler does) + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // 3. Type "how are you?" -- through real dispatch simulation + simulate_dispatch_non_continue(&mut app, "how are you?"); + + // ASSERT: quarry stays None: no goal in the system prompt, only the + // paused-command evaluation note in the message. + assert!( + app.hunt.quarry.is_none(), + "quarry must be None: system prompt has no active goal" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repos"), + "paused_quarry preserved for WorkBench display" + ); + + // ASSERT: LLM-evaluation cleared pause state and preserved paused_quarry. + let summary = sidebar_work_summary(&mut app); + // WorkBench shows ⏸ with the paused goal (via paused_quarry fallback) + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "WorkBench must show Paused indicator (paused_quarry preserved)" + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench must show the goal (restored from paused_quarry)" + ); + + // 4. Type "resume the paused command" -- same as any message now. + simulate_dispatch_non_continue(&mut app, "resume the paused command"); + + // ASSERT: LLM evaluation note handles intent, no local keyword restore. + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + // ASSERT: pause is cleared and paused_quarry preserved for WorkBench. + assert!(!app.paused, "pause cleared after dispatch"); + assert!( + app.paused_quarry.is_some(), + "paused_quarry preserved for WorkBench display" + ); + // ASSERT: WorkBench still shows the paused command. + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "pause indicator: {:?}", + summary.pause_indicator + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench shows paused goal via paused_quarry fallback" + ); + } + + #[test] + fn dispatch_continue_uses_llm_evaluation_note_and_keeps_paused_context() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Type "continue" -- through real dispatch simulation + simulate_dispatch_non_continue(&mut app, "continue"); + + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + assert!( + app.paused_quarry.is_some(), + "paused_quarry preserved for WorkBench display" + ); + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.pause_indicator.as_deref(), + Some("(Paused)"), + "pause indicator: {:?}", + summary.pause_indicator + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench shows paused goal via paused_quarry fallback" + ); + } + + #[test] + fn dispatch_resume_starts_with_uses_llm_evaluation_note() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Build deploy".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + simulate_dispatch_non_continue(&mut app, "resume the paused command"); + + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Build deploy"), + "paused_quarry preserved for WorkBench display" + ); + } + + #[test] + fn dispatch_after_resume_word_keeps_llm_evaluation_state() { + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // "resume" + simulate_dispatch_non_continue(&mut app, "resume"); + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation handles intent" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan repos"), + "paused_quarry preserved after resume wording" + ); + + // Now paused_quarry is preserved, paused is false. + // TurnStarted clears paused_cancelled and pausable. + app.paused_cancelled = false; + app.pausable = false; + + // User types "wait no" -- should be normal message, no pause interference + simulate_dispatch_non_continue(&mut app, "wait no"); + + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation handles all messages" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan repos"), + "paused_quarry remains available for WorkBench display" + ); + } + + #[test] + fn model_must_not_continue_paused_command_after_normal_message() { + // This test documents the EXPECTED behaviour: + // After pause -> "how are you?" -> model responds -> the model + // must NOT continue the paused scan command. + // + // Current reality: the model DOES continue because it sees the + // old request in conversation history. This test passes at the + // CODE level (our state transitions are correct), but the MODEL + // behaviour still violates this expectation. + // + // This is a CONTRACT test — if we later add conversation-trimming + // or system-prompt injection, this test must still hold. + let mut app = create_test_app(); + + // 1. Start pausable command with a clear goal + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.pausable = true; + + // 2. Pause (ESC) + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // 3. Type "how are you?" — non-continue message + simulate_dispatch_non_continue(&mut app, "how are you?"); + + // LLM-evaluation: quarry stays None (no goal in system prompt). + // The note appended to the message tells the LLM to evaluate whether + // the user wants to continue or not — no fragile keyword matching. + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None, note goes to message not system prompt" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry preserved for WorkBench display" + ); + + // WorkBench should show the paused command for user awareness + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repositories"), + "WorkBench must remind user of the paused command" + ); + + // 4. Now simulate what happens AFTER the model responds to "how are you?": + // The engine has finished processing and the turn completes. + // At this point, there should be NO mechanism that re-activates + // the paused command. The model should NOT pick it up. + // + // With LLM-evaluation: quarry stays None (no goal in system prompt). + // paused_quarry is preserved (WorkBench shows the paused command). + // The evaluation note appended to the message tells the LLM to + // NOT continue unless the user asks for it. + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None (no goal pressure on model)" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan nested git repositories"), + "paused_quarry preserved for WorkBench display" + ); + + // If this test fails, it means a code change RE-ACTIVATED the + // paused command's goal, causing the model to resume it unprompted. + } + + #[test] + fn non_continue_message_injects_cancellation_notice() { + // When a non-continue message is sent while paused, the message + // MUST include a cancellation notice so the model sees it in the + // conversation and does NOT continue the old command unprompted. + let mut msg = String::from("how are you?"); + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan nested git repositories".to_string()); + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Simulate the exact non-continue branch from dispatch_user_message: + let paused_name = app + .paused_quarry + .as_deref() + .map(|q| q.split(['\n', '\r']).next().unwrap_or(q)) + .unwrap_or("the previous command"); + msg.push_str(&format!( + "\n\n---\n[The user paused: {paused_name}. Respond only to the new message above. Do NOT execute the paused command.]" + )); + app.paused = false; + app.hunt.quarry = None; + + assert!( + msg.contains("user paused"), + "FAIL: message must contain cancellation notice so model does not continue paused command" + ); + assert!( + msg.contains("Scan nested git repositories"), + "FAIL: notice must name the paused command so model knows what to avoid" + ); + } + + #[test] + fn workbench_shows_paused_after_dispatch_non_continue() { + // Direct sidebar state test using dispatch simulation: + // the WorkBench must show the paused command after a non-continue + // message is dispatched through the real flow. + let mut app = create_test_app(); + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + simulate_dispatch_non_continue(&mut app, "how are you?"); + + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Deploy to staging"), + "WorkBench must show the goal (restored from paused_quarry)" + ); + // LLM-evaluation: pause cleared, quarry restored. + // Pause indicator may be None or (Paused) depending on timing. + assert!( + summary.pause_indicator.is_none() + || summary.pause_indicator.as_deref() == Some("(Paused)"), + "WorkBench pause indicator: {:?} (expected None or Paused)", + summary.pause_indicator + ); + assert!( + app.hunt.quarry.is_none(), + "LLM-evaluation: quarry None (note goes to message)" + ); + } + + #[test] + fn workbench_rendered_output_keeps_paused_after_non_continue() { + // Renders the actual work panel lines and checks the final output + // — catches regressions where summary is correct but the render + // path produces nothing (e.g. push_work_goal_lines exits early). + let mut app = create_test_app(); + app.hunt.quarry = Some("Deploy to staging".to_string()); + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + simulate_dispatch_non_continue(&mut app, "how are you?"); + + let summary = sidebar_work_summary(&mut app); + let lines = work_panel_lines(&summary, 80, 10, PaletteMode::Dark, &palette::UI_THEME); + + // If the goal_objective or pause_indicator is somehow lost in the + // render pipeline, lines will be empty or not contain the expected + // text. This catches the "WorkBench went blank" bug directly. + assert!( + !lines.is_empty(), + "FAIL: rendered lines are empty — WorkBench went blank" + ); + let first = lines.first().map(|l| l.to_string()).unwrap_or_default(); + // LLM-evaluation: paused_quarry preserved → workflow_paused=true → icon ⏸. + assert!( + first.contains('⏸'), + "FAIL: rendered line missing pause icon, got: {first}" + ); + assert!( + first.contains("Deploy to staging"), + "FAIL: rendered line missing goal text, got: {first}" + ); + } + + #[test] + fn resume_switches_icon_from_pause_to_play() { + // When the user resumes a paused command, the WorkBench icon must + // change from ⏸ (pause) to ▶ (play). The (Pausing)/(Paused) + // indicator must disappear because paused_quarry is consumed. + let mut app = create_test_app(); + app.hunt.quarry = Some("Scan repos".to_string()); + app.pausable = true; + + // Pause + app.paused_quarry = app.hunt.quarry.clone(); + app.hunt.quarry = None; + app.paused = true; + + // Verify paused state shows pause icon + let summary = sidebar_work_summary(&mut app); + assert_eq!(summary.pause_indicator.as_deref(), Some("(Paused)")); + + // Resume: consume paused_quarry, restore hunt.quarry + app.hunt.quarry = app.paused_quarry.take(); + app.paused = false; + app.pausable = false; + + // Simulate TurnStarted (engine started processing) + app.is_loading = true; + + // The icon should now be ▶ (play), NOT ⏳/⏸ + let summary = sidebar_work_summary(&mut app); + assert!( + summary.pause_indicator.is_none(), + "FAIL: resume left pause_indicator={:?} — icon stuck on pause", + summary.pause_indicator + ); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan repos"), + "goal must be restored on resume" + ); + } + + #[test] + fn llm_evaluation_preserves_paused_quarry_from_any_path() { + // Verifies that submit/steer routing does not locally intercept + // "resume" wording. The paused command stays available to WorkBench, + // and the LLM evaluation note handles intent. + let mut app = create_test_app(); + app.paused_quarry = Some("Scan nested git repos".to_string()); + app.paused = false; // unpaused state after "how are you?" + app.is_loading = true; // model still processing — would go Steer + app.hunt.quarry = None; + + // With LLM evaluation: no keyword interception. + // paused_quarry stays intact, hunt.quarry stays None. + assert!( + app.paused_quarry.is_some(), + "paused_quarry preserved for WorkBench display" + ); + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + + // Even if a later turn completes, WorkBench can still show the paused + // goal from paused_quarry without locally reactivating it. + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + app.is_loading = false; + + let summary = sidebar_work_summary(&mut app); + assert_eq!( + summary.goal_objective.as_deref(), + Some("Scan nested git repos"), + "WorkBench should show completed goal" + ); + // goal_completed takes priority over pause_indicator for the icon + assert!( + summary.goal_completed, + "completed command must show checkmark (✓ overrides ⏸)" + ); + assert!( + summary.pause_indicator.is_some(), + "paused_quarry still set — indicator preserved (✓ icon)", + ); + } + + #[test] + fn resume_phrase_in_middle_uses_llm_evaluation_note() { + let mut app = create_test_app(); + app.paused_quarry = Some("Scan repos".to_string()); + app.hunt.quarry = None; + + simulate_dispatch_non_continue( + &mut app, + "can you please continue the paused slash command", + ); + + assert!( + app.hunt.quarry.is_none(), + "quarry stays None — LLM evaluation note handles intent" + ); + assert_eq!( + app.paused_quarry.as_deref(), + Some("Scan repos"), + "paused_quarry preserved for WorkBench display" + ); + } } diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index 05905f747..3052c9988 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -66,7 +66,7 @@ pub fn apply_slash_menu_selection( if append_space && !command.ends_with(' ') && !command.contains(char::is_whitespace) - && let Some(info) = commands::get_command_info(command.trim_start_matches('/')) + && let Some(info) = commands::registry().get_info(command.trim_start_matches('/')) && info.name != "change" && (info.usage.contains('<') || info.usage.contains('[')) { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fadf..de075ae26 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1614,6 +1614,16 @@ async fn run_event_loop( } } EngineEvent::TurnStarted { turn_id } => { + let dispatch_elapsed_ms = app + .dispatch_started_at + .map(|started| started.elapsed().as_millis()) + .unwrap_or_default(); + tracing::debug!( + target: "turn_dispatch", + turn_id = %turn_id, + dispatch_elapsed_ms, + "TUI observed TurnStarted" + ); app.suppress_stream_events_until_turn_complete = false; app.is_loading = true; app.offline_mode = false; @@ -1623,6 +1633,12 @@ async fn run_event_loop( app.streaming_state.reset(); app.streaming_message_index = None; app.streaming_thinking_active_entry = None; + // Reset pause state for new turn. + // Note: pausable is NOT reset here — it is set by + // try_dispatch_user_command for pausable commands and + // persists until the user presses Esc or the turn ends. + app.paused = false; + app.paused_cancelled = false; let now = Instant::now(); app.turn_started_at = Some(now); app.turn_last_activity_at = Some(now); @@ -1652,6 +1668,12 @@ async fn run_event_loop( tool_catalog, base_url, } => { + tracing::debug!( + target: "turn_dispatch", + status = ?status, + runtime_turn_id = app.runtime_turn_id.as_deref().unwrap_or("<unknown>"), + "TUI observed TurnComplete" + ); app.session.last_tool_catalog = tool_catalog; app.session.last_base_url = base_url; let was_locally_cancelled = app.suppress_stream_events_until_turn_complete; @@ -1713,6 +1735,21 @@ async fn run_event_loop( } crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), }); + // Mark goal as completed when turn finishes successfully + // so the WorkBench shows a green checkmark instead of + // the diamond icon. + if status == crate::core::events::TurnOutcomeStatus::Completed + && app.hunt.quarry.is_some() + { + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunted; + } + + // Keep pause state visible after the turn ends so the + // WorkBench continues to show the pause indicator. + // Clear `pausable` so a fresh user message starts clean, + // but keep `paused`/`paused_cancelled` for the sidebar. + app.pausable = false; + if matches!( status, crate::core::events::TurnOutcomeStatus::Interrupted @@ -1949,7 +1986,16 @@ async fn run_event_loop( apply_engine_error_to_app(app, envelope); } EngineEvent::Status { message } => { - app.status_message = Some(message); + // Late engine status events (e.g. "Request was Paused" + // from a stale Op::SetPaused) must not overwrite a + // more recent cancellation status set by the UI. + if app.paused_cancelled + && (message == "Request was Paused" || message == "Request was Resumed") + { + // discard — cancel/resume status takes priority + } else { + app.status_message = Some(message); + } } EngineEvent::SessionUpdated { session_id, @@ -3445,10 +3491,54 @@ async fn run_event_loop( } EscapeAction::CancelRequest => { app.backtrack.reset(); - engine_handle.cancel(); - mark_active_turn_cancelled_locally(app); - current_streaming_text.clear(); - app.status_message = Some("Request cancelled".to_string()); + if app.paused { + // Cancelling while paused — stop the engine turn. + app.paused_cancelled = true; + app.paused = false; + // Restore the quarry from paused_quarry so the + // WorkBench shows the original goal (with ✘ icon). + // Cleared in dispatch_user_message when user types next. + app.hunt.quarry = app.paused_quarry.take(); + app.hunt.verdict = crate::tui::app::HuntVerdict::Hunting; + app.active_allowed_tools = None; + engine_handle.set_paused(false); + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + current_streaming_text.clear(); + app.status_message = Some("Request was Cancelled".to_string()); + } else { + engine_handle.cancel(); + mark_active_turn_cancelled_locally(app); + current_streaming_text.clear(); + app.status_message = Some("Request cancelled".to_string()); + } + } + EscapeAction::PauseCommand => { + if app.paused { + // Already paused — resume + tracing::debug!(target: "pausable", "PauseCommand — resuming"); + app.hunt.quarry = app.paused_quarry.take(); + engine_handle.set_paused(false); + app.paused = false; + app.paused_at = None; + } else { + // First ESC — pause + tracing::debug!(target: "pausable", "PauseCommand — pausing"); + // Save the current goal so we can restore it on resume. + // Set a pause message so the system prompt tells + // the model the command is on hold instead of + // continuing the original request. + app.paused_quarry = app.hunt.quarry.clone(); + // Clear the quarry so the goal continuation + // system doesn't prompt the model to resolve + // a "pause" goal. The pause gate + system + // prompt are sufficient to prevent execution. + app.hunt.quarry = None; + engine_handle.set_paused(true); + app.paused = true; + app.paused_at = Some(std::time::Instant::now()); + app.paused_cancelled = false; + } } EscapeAction::DiscardQueuedDraft => { app.backtrack.reset(); @@ -4268,15 +4358,26 @@ fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage { } fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool) -> bool { + let dispatch_elapsed = app + .dispatch_started_at + .map(|started| now.saturating_duration_since(started)); if app.is_loading && app.runtime_turn_status.is_none() && !has_running_agents && !app.is_compacting && !app.is_purging - && app.dispatch_started_at.is_some_and(|started| { - now.saturating_duration_since(started) > DISPATCH_WATCHDOG_TIMEOUT - }) + && dispatch_elapsed.is_some_and(|elapsed| elapsed > DISPATCH_WATCHDOG_TIMEOUT) { + tracing::warn!( + target: "turn_dispatch", + elapsed_ms = dispatch_elapsed + .map(|elapsed| elapsed.as_millis()) + .unwrap_or_default(), + has_running_agents, + is_compacting = app.is_compacting, + is_purging = app.is_purging, + "turn dispatch watchdog fired before TurnStarted" + ); app.is_loading = false; app.dispatch_started_at = None; app.turn_started_at = None; @@ -4324,6 +4425,12 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool now.saturating_duration_since(last_activity) > TURN_STALL_WATCHDOG_TIMEOUT }) { + tracing::warn!( + target: "turn_dispatch", + runtime_turn_id = app.runtime_turn_id.as_deref().unwrap_or("<unknown>"), + runtime_turn_status = app.runtime_turn_status.as_deref().unwrap_or("<none>"), + "turn stall watchdog fired before TurnComplete" + ); // Finalize in-flight thinking / assistant / tool cells so the // transcript doesn't show permanent spinners after recovery. streaming_thinking::finalize_current(app); @@ -4790,12 +4897,70 @@ fn queued_message_content_for_app( } } +/// Append an evaluation note to the message when there's a paused_quarry. +/// The LLM reads the note and decides whether to continue the paused command. +fn add_paused_evaluation_note(app: &mut App, message: &mut QueuedMessage) { + if app.paused_quarry.is_some() { + let name = app + .paused_quarry + .as_deref() + .and_then(|q| q.split(['\n', '\r']).next()) + .unwrap_or("the paused command"); + let note = format!( + "\n\n---\n[Note: The user previously paused: {name}. If they ask to \ + continue or resume it in their message above, do so. Otherwise, \ + ignore the paused command and respond only to the new message.]" + ); + message.display.push_str(¬e); + app.hunt.quarry = None; + } +} + async fn dispatch_user_message( app: &mut App, config: &Config, engine_handle: &EngineHandle, mut message: QueuedMessage, ) -> Result<()> { + // When paused or paused_quarry: keep the paused goal out of the system + // prompt and append an evaluation note. The LLM decides whether to + // continue the paused command based on the user's message. + if app.paused || app.paused_quarry.is_some() { + if app.paused_quarry.is_some() { + add_paused_evaluation_note(app, &mut message); + } + if app.paused { + engine_handle.cancel(); + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + engine_handle.set_paused(false); + app.status_message = None; + } + // Fall through — message goes to engine as a normal user message. + } + + // Safety sync: if the app-level pause was cleared (e.g. by a new slash + // command in try_dispatch_user_command), make sure the engine flag is + // also cleared so the pause gate doesn't block the new command's tools. + // NOTE: check only app.paused — app.pausable may be true because the + // new command's frontmatter already set it, but the engine flag from + // the OLD paused command may still be hanging. + if !app.paused { + engine_handle.set_paused(false); + } + + // If we're in a cancelled state and the user is sending a new message, + // clear the quarry so the system prompt doesn't include the old goal. + // The quarry is kept visible in the WorkBench (via pause_indicator="(Cancelled)") + // until the user types, at which point it's naturally replaced by the new goal. + if app.paused_cancelled { + app.hunt.quarry = None; + app.paused_cancelled = false; + } + // #1364: run mutable `message_submit` hooks before dispatch. Hooks see the // user's display text and may replace or block it before file mentions, // skill wrapping, history, and model input are resolved. @@ -4905,7 +5070,9 @@ async fn dispatch_user_message( auto_selection .as_ref() .map(|selection| selection.model.clone()) - .unwrap_or_else(|| commands::auto_model_heuristic(&message.display, &app.model)) + .unwrap_or_else(|| { + crate::model_routing::auto_model_heuristic(&message.display, &app.model) + }) } else { app.model.clone() }; @@ -4944,6 +5111,23 @@ async fn dispatch_user_message( app.last_effective_model = None; } + let dispatch_content_bytes = content.len(); + let dispatch_mode = app.mode.label(); + let dispatch_model = effective_model.clone(); + let dispatch_reasoning_effort = effective_reasoning_effort.clone(); + tracing::debug!( + target: "turn_dispatch", + content_bytes = dispatch_content_bytes, + mode = dispatch_mode, + model = %dispatch_model, + reasoning_effort = ?dispatch_reasoning_effort, + auto_model = app.auto_model, + allow_shell = app.allow_shell, + trust_mode = app.trust_mode, + auto_approve = app.mode == AppMode::Yolo, + "TUI sending SendMessage op to engine" + ); + if let Err(err) = engine_handle .send(Op::SendMessage { content, @@ -4964,12 +5148,29 @@ async fn dispatch_user_message( }) .await { + tracing::warn!( + target: "turn_dispatch", + ?err, + content_bytes = dispatch_content_bytes, + mode = dispatch_mode, + model = %dispatch_model, + "TUI failed to send SendMessage op to engine" + ); app.is_loading = false; app.dispatch_started_at = None; app.last_send_at = None; return Err(err); } + tracing::debug!( + target: "turn_dispatch", + content_bytes = dispatch_content_bytes, + mode = dispatch_mode, + model = %dispatch_model, + elapsed_ms = dispatch_started_at.elapsed().as_millis(), + "TUI queued SendMessage op to engine" + ); + Ok(()) } @@ -5365,7 +5566,11 @@ async fn switch_provider( .await; let persist_warning = (|| -> anyhow::Result<()> { - commands::persist_root_string_key(app.config_path.as_deref(), "provider", target.as_str())?; + crate::config_persistence::persist_root_string_key( + app.config_path.as_deref(), + "provider", + target.as_str(), + )?; let mut settings = crate::settings::Settings::load()?; settings.default_provider = Some(target.as_str().to_string()); @@ -5905,8 +6110,7 @@ async fn apply_command_result( } else { let history_json = serde_json::to_string_pretty(&app.api_messages) .unwrap_or_else(|_| "[]".to_string()); - match crate::commands::share::perform_share(&history_json, &model, &mode).await - { + match crate::share_export::perform_share(&history_json, &model, &mode).await { Ok(url) => format!("Session shared! URL: {url}"), Err(err) => format!("Share failed: {err}"), } @@ -6236,8 +6440,21 @@ async fn execute_command_input( async fn steer_user_message( app: &mut App, engine_handle: &EngineHandle, - message: QueuedMessage, + mut message: QueuedMessage, ) -> Result<()> { + // Add a note for the Steer path (bypasses dispatch_user_message). + // Also clear pause state — the Steer path bypasses dispatch entirely. + if app.paused { + engine_handle.cancel(); + app.paused = false; + app.paused_at = None; + app.paused_cancelled = false; + app.pausable = false; + app.active_snapshot = None; + engine_handle.set_paused(false); + app.status_message = None; + } + add_paused_evaluation_note(app, &mut message); let cwd = std::env::current_dir().ok(); let references = crate::tui::file_mention::context_references_from_input( &message.display, @@ -6293,6 +6510,9 @@ async fn submit_or_steer_message( engine_handle: &EngineHandle, message: QueuedMessage, ) -> Result<()> { + // No keyword interception for "continue"/"resume". Immediate dispatch and + // steer both append the paused-command evaluation note before reaching the + // model, and the model decides intent from the full message. match app.decide_submit_disposition() { SubmitDisposition::Immediate => { dispatch_user_message(app, config, engine_handle, message).await @@ -7187,7 +7407,7 @@ async fn handle_view_events( value, persist, } => { - let result = commands::set_config_value(app, &key, &value, persist); + let result = crate::config_actions::set_config_value(app, &key, &value, persist); // Theme / background changes require a full terminal repaint // because ratatui's incremental diff may miss color-only // changes in cells that were rendered with theme-resolved @@ -7232,7 +7452,7 @@ async fn handle_view_events( app.status_items = items.clone(); app.needs_redraw = true; if final_save { - match commands::persist_status_items(&items) { + match crate::config_persistence::persist_status_items(&items) { Ok(path) => { app.status_message = Some(format!("Status line saved to {}", path.display())); @@ -7308,7 +7528,7 @@ async fn handle_view_events( } ViewEvent::ModeSelected { mode } => { let prior_mode = app.mode; - let msg = commands::switch_mode(app, mode); + let msg = commands::groups::config::mode::mode_impl::switch_mode(app, mode); if app.mode != prior_mode { sync_mode_update(engine_handle, app.mode).await; } diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index 4124fcf51..c06584dda 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -202,7 +202,7 @@ impl HelpView { fn build_entries(locale: Locale) -> Vec<HelpEntry> { let mut entries = Vec::new(); - for command in commands::COMMANDS { + for command in commands::registry().infos() { let label = format!("/{}", command.name); let localized = command.description_for(locale); let description = if command.aliases.is_empty() { @@ -515,7 +515,7 @@ mod tests { fn empty_filter_lists_all_entries() { let view = HelpView::new(); // Total = registered slash commands + catalogued keybindings. - let expected = commands::COMMANDS.len() + KEYBINDINGS.len(); + let expected = commands::registry().infos().len() + KEYBINDINGS.len(); assert_eq!(view.filtered.len(), expected); assert_eq!(view.entries.len(), expected); } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9bed..c3ac0c0db 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -2149,7 +2149,7 @@ pub(crate) fn slash_completion_hints( // ── Phase 2: contains (substring) matches ───────────────────────── // Medium priority — broader catching. if completing_skill_arg.is_none() { - for cmd in commands::COMMANDS { + for cmd in commands::registry().infos() { let name = format!("/{}", cmd.name); if seen.contains(&name) { continue; @@ -2176,7 +2176,7 @@ pub(crate) fn slash_completion_hints( // ── Phase 3: fuzzy subsequence matches ──────────────────────────── // Lowest priority — characters in order, not necessarily consecutive. if completing_skill_arg.is_none() { - for cmd in commands::COMMANDS { + for cmd in commands::registry().infos() { let name = format!("/{}", cmd.name); if seen.contains(&name) { continue; @@ -2270,7 +2270,7 @@ pub(crate) fn slash_completion_hints( if command_key.eq_ignore_ascii_case(&prefix_lower) { return 0; } - if let Some(info) = commands::get_command_info(command_key) + if let Some(info) = commands::registry().get_info(command_key) && info .aliases .iter() @@ -2293,7 +2293,8 @@ fn all_command_names_matching_loaded( user_commands: &[(String, String)], ) -> Vec<String> { let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); - let mut result: Vec<String> = commands::COMMANDS + let mut result: Vec<String> = commands::registry() + .infos() .iter() .filter(|cmd| { cmd.name.starts_with(&prefix) || cmd.aliases.iter().any(|a| a.starts_with(&prefix)) @@ -2323,7 +2324,7 @@ fn push_command_entry( locale: crate::localization::Locale, user_commands: &[(String, String)], ) { - let (description, alias_hint) = if let Some(info) = commands::get_command_info(command_key) { + let (description, alias_hint) = if let Some(info) = commands::registry().get_info(command_key) { let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) { info.aliases .iter() diff --git a/crates/tui/tests/eval_harness.rs b/crates/tui/tests/eval_harness.rs index 00a5d26b3..7591d3d6d 100644 --- a/crates/tui/tests/eval_harness.rs +++ b/crates/tui/tests/eval_harness.rs @@ -2,6 +2,9 @@ use std::fs; +#[path = "../src/shell_invocation.rs"] +mod shell_invocation; + #[path = "../src/eval.rs"] mod eval; #[path = "../src/shell_dispatcher.rs"]